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
+84
-2
@@ -1,8 +1,8 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String,
|
||||
Table, Text,
|
||||
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
|
||||
String, Table, Text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -116,6 +116,17 @@ class Adventure(Base):
|
||||
# How many actions have already been folded into memories / the story summary.
|
||||
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Phase 14: where the story is being played — which branch, and the depth of
|
||||
# its newest node. Deliberately NOT a ForeignKey: branches.adventure_id
|
||||
# already points this way, and a second constraint back would make the two
|
||||
# tables a cycle that create_all cannot order (the fix for that is
|
||||
# use_alter, which SQLite has no ALTER for). It is a cache of a pointer, and
|
||||
# `tree.head_branch` treats a head naming a branch that no longer exists as
|
||||
# a bug to recover from rather than a state to honour.
|
||||
head_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The depth of the tip, so the next node is always head_depth + 1.
|
||||
# NO_DEPTH (-1) for an adventure with no actions yet.
|
||||
head_depth: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -140,6 +151,48 @@ class Adventure(Base):
|
||||
)
|
||||
|
||||
|
||||
class Branch(Base):
|
||||
"""Phase 14 — one path through an adventure's story tree.
|
||||
|
||||
A branch does not own a copy of the story: it holds the nodes played on it
|
||||
and *borrows* everything before its fork point from its ancestors. Reading
|
||||
branch C means reading C's nodes, plus B's up to where C left it, plus A's
|
||||
up to where B left it — which is what `lineage` spells out, so a read is an
|
||||
OR-clause per entry instead of a walk up parent pointers.
|
||||
|
||||
Until forking ships there is exactly one root branch per adventure and
|
||||
every node hangs off it. That is not a half-migrated state: a linear story
|
||||
*is* a tree with one branch, which is why writing these columns changes
|
||||
nothing anyone can observe.
|
||||
|
||||
No ORM relationships on purpose. `actions.branch_id` and `memories
|
||||
.branch_id` carry ON DELETE CASCADE, so the database removes a deleted
|
||||
branch's nodes; a relationship would have SQLAlchemy load them all to do
|
||||
the same thing, and loading every action of a branch is the exact cost the
|
||||
windowed reads exist to avoid.
|
||||
"""
|
||||
|
||||
__tablename__ = "branches"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
# NULL on a root branch.
|
||||
parent_branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# The depth this branch left its parent at, stored when the fork happens and
|
||||
# never inferred afterwards. Inferring it from where two branches' nodes
|
||||
# first differ would be a guess about how the story was played — and a wrong
|
||||
# one as soon as an attempt happens to repeat its parent's text.
|
||||
fork_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# The ancestry, newest first: [[branch_id, max_depth], ...] where max_depth
|
||||
# is NULL for "to the tip" and otherwise the fork_depth of the branch
|
||||
# beneath it, inclusive. Computed once at fork from the parent's lineage
|
||||
# plus one entry, so no read ever reconstructs it.
|
||||
lineage: Mapped[list] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
||||
|
||||
@@ -169,6 +222,18 @@ class Memory(Base):
|
||||
# Action index range this memory summarizes (null for manual memories).
|
||||
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Phase 14: the node that produced this memory — the last action it
|
||||
# summarises. Anything derived attaches to the node it came from, which is
|
||||
# what makes a fork free: a shared ancestor's memories are shared
|
||||
# automatically, and a memory covering a stretch of branch B is invisible
|
||||
# from any path that does not go through B.
|
||||
#
|
||||
# `depth` is NULL for a hand-written memory, which no node produced; that
|
||||
# reads as "belongs to the adventure, not to a path".
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Whether embedding_blob is set. Maintained on write by memorybank
|
||||
# .set_vector, for the same reason actions.variant_count exists beside
|
||||
# actions.variants: every reader wants the one-bit answer and none of them
|
||||
@@ -212,10 +277,27 @@ class StoryCard(Base):
|
||||
|
||||
class Action(Base):
|
||||
__tablename__ = "actions"
|
||||
# Phase 14: every read of a story is "this branch up to this depth, or that
|
||||
# branch up to that depth, ...", so (branch_id, depth) is the shape every
|
||||
# one of those clauses wants an index on.
|
||||
__table_args__ = (Index("ix_actions_branch_depth", "branch_id", "depth"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
|
||||
index: Mapped[int] = mapped_column(Integer)
|
||||
# Phase 14: the node's place in the tree. `depth` is a position along *a*
|
||||
# path, not a global turn number — A4 and B4 are alternatives, not
|
||||
# duplicates — and it replaces `index` as the ordering key.
|
||||
#
|
||||
# Nullable because ALTER TABLE cannot add a NOT NULL column with no
|
||||
# default and there is no sensible default for "which branch": the
|
||||
# migration fills them, `tree.place_action` fills them for new nodes, and
|
||||
# from SP2 on a NULL branch_id is a row no read can see. Legacy `index`
|
||||
# stays beside them, unread, until the tree is proven live (SP8 drops it).
|
||||
branch_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
# Reasoning-model "thinking" that preceded the text (AI actions only).
|
||||
|
||||
Reference in New Issue
Block a user