Drop the eight legacy columns SP8 left behind

Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`,
`variant_count`, `state_before`, and `world_state_before`, plus
`adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in
SQLite, so migration 71 quotes it.

Nothing outside the migrations read these. `models.py`, `schemas.py`, and
`ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders
by `id`, and `attempts.renumber`, `context.history.max_action_index`, and
`nodes.next_index` are deleted.

Two changes keep the migration replayable on a `create_all` database:

- `_split_variants_into_siblings` wrote through the live ORM table, so it
  stopped compiling once migration 66 removed five of its columns. It now
  writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`.
- Five data passes read columns these migrations drop. Each now calls
  `_has_columns` and returns early when the columns are absent.

`bootstrap` takes a `through` version so a migration test can stop at the
schema it asserts on.

555 tests pass, up from 549. Eight of the new cases assert each column is
gone after a real schema-45 database migrates all the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
This commit is contained in:
parththakkar106
2026-08-29 17:15:49 +05:30
committed by Parth
co-authored by Claude Opus 5
parent e0bf2b61d9
commit f1bebe18d0
41 changed files with 477 additions and 378 deletions
+15 -37
View File
@@ -2,7 +2,7 @@
A retry used to rewrite the AI action in place and append the discarded attempt
to a JSON list on the same row. Seven separate bugs came from that arrangement.
The row's `text` duplicated one entry of a repeating group, `variant_count`
The row's `text` duplicated one entry of a repeating group, a second column
duplicated its length, and every reader that touched the story during a retry
had to be told to ignore the row.
@@ -25,10 +25,11 @@ that maintains either one:
avoid. The prompt therefore moves with the live flag, and a superseded sibling
keeps only its own slices.
Ordering inside a group comes from `variant_index`, which is an explicit ordinal
rather than `created_at`. Two attempts made in the same second still have to page
in the order they were made, and the migration that split the old JSON lists had
to state the order rather than reconstruct it.
Ordering inside a group comes from `id`, not from `created_at`. Two attempts made
in the same second still have to page in the order they were made, and `id`
increases with every insert. SP8 dropped `variant_index`, an explicit ordinal
that carried the same order, once a run of the suite confirmed that the two
agreed in every group.
"""
import copy
@@ -82,7 +83,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
models.Action.depth == action.depth,
models.Action.parent_id.is_(None),
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
return (
@@ -91,7 +92,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]:
models.Action.adventure_id == action.adventure_id,
models.Action.parent_id == action.parent_id,
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
@@ -190,8 +191,8 @@ def add_attempt(
"""Places `replacement` next to `previous` as the newer attempt at that turn.
The placement is done here rather than through `tree.place_action`, which
would read the depth from the legacy `index` and move the head. A sibling is
not a new turn. It is another attempt at the turn the head is already on.
moves the head. A sibling is not a new turn. It is another attempt at the
turn the head is already on.
"""
replacement.branch_id = previous.branch_id
replacement.depth = previous.depth
@@ -202,18 +203,10 @@ def add_attempt(
# away from this turn.
replacement.parent_id = previous.parent_id
replacement.live = True
# Use the end of the group rather than one past `previous`. The two match
# only when `previous` is the newest attempt. Switch a three-attempt turn
# back to attempt 1 and retry, and `previous.variant_index + 1` collides with
# attempt 2. `renumber` then breaks the tie by id and places the new attempt
# between attempts 2 and 3, so the pager walks the attempts in an order they
# were not made in. `group` returns oldest first, and `replacement` is not in
# it yet.
siblings = group(db, previous)
replacement.variant_index = 1 + max(
(s.variant_index for s in siblings if s.variant_index is not None),
default=previous.variant_index or 0,
)
# The replacement takes its place at the end of the group, because `group`
# orders by `id` and this row has no id yet. Switching a three-attempt turn
# back to attempt 1 and retrying therefore still pages 1, 2, 3, 4, which is
# the order the attempts were made in.
previous.live = False
# The replacement was assembled with a fresh snapshot, so the prompt for
# this turn is now the one it carries. The superseded attempt keeps only the
@@ -226,8 +219,7 @@ def make_live(
) -> list[models.Action]:
"""Makes `node` the attempt the story tells, and restores its outcome.
Returns the group, renumbered, so that a caller reporting on it does not read
it twice.
Returns the group, so that a caller reporting on it does not read it twice.
"""
rows = group(db, node)
previous = live_in(rows)
@@ -236,23 +228,9 @@ def make_live(
for row in rows:
row.live = row is node
restore_state(adventure, node)
renumber(rows)
return rows
def renumber(rows: list[models.Action]) -> None:
"""Refreshes the group-shape cache that the page response reads.
`variant_count` is 0 rather than 1 for a turn nobody retried, because the
pager asks whether there is anything to page through, and for a single
attempt the answer is no.
"""
count = len(rows) if len(rows) > 1 else 0
for i, row in enumerate(rows):
row.variant_index = i
row.variant_count = count
# ------------------------------------------------- the prompt, stored once
def keep_own_slices(node: models.Action) -> None:
+9 -27
View File
@@ -14,8 +14,8 @@ Version 2 carries the tree. It holds three things version 1 could not, and each
one is required:
* The branches, because a forked adventure is two stories and a flat list holds
one. A version 1 export interleaved them by `index`, which read as a garbled
story rather than as lost data.
one. A version 1 export interleaved them by turn number, which read as a
garbled story rather than as lost data.
* `live`, because a coordinate can hold several attempts at one turn and exactly
one of them is the story.
* Both after-snapshots, because they are what a branch switch and an undo
@@ -26,22 +26,14 @@ one is required:
A bundle carries what was chosen, never what is derived. The head branch, the
fork points, the live flags, and the anchors are decisions somebody made, so
they are in the file. `lineage`, the head depth, `index`, and the variant
ordinals are all computed from those, and the import recomputes them:
they are in the file. `lineage` and the head depth are computed from those, and
the import recomputes them:
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
put a second source of truth for one fact into a file anyone can hand-edit,
and the two could then disagree without any read reporting it.
* The head depth is the tip of the head branch, which is a fact about the nodes
that arrived with it.
* `index` is the legacy column SP8 drops. Its one remaining job is to give the
next row a number nothing else holds, which is a fact about the adventure
rather than about a path, so `depth` cannot serve: two branches each have a
node at depth 4. The import allocates one index per turn instead, which keeps
`max_action_index` correct and keeps siblings sharing an index the way SP4
leaves them.
* `attempts.renumber` maintains the variant ordinals, and it is the only place
allowed to.
Every hand-editable coordinate is therefore checked before a row is written, in
`plan`, rather than repaired afterwards. An import that fails partway leaves an
@@ -98,8 +90,7 @@ def export(db: Session, adventure: models.Adventure) -> dict:
undefer(models.Action.world_state_after),
)
.order_by(
models.Action.branch_id, models.Action.depth,
models.Action.variant_index, models.Action.id,
models.Action.branch_id, models.Action.depth, models.Action.id,
)
.all()
)
@@ -168,8 +159,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
node = {
"branch": _local(action.branch_id, local),
# A pre-tree row's depth is the number `index` already holds.
"depth": action.depth if action.depth is not None else action.index,
"depth": action.depth,
"live": bool(action.live),
"type": action.type,
"text": action.text,
@@ -496,24 +486,17 @@ def _write_nodes(
) -> None:
"""Writes the nodes, grouped into the turns they are attempts at.
Two values are allocated here rather than read from the file. `index` is
issued once per turn, in the order the bundle lists them, so siblings share
one index and no two coordinates do. `max_action_index` needs that to keep
issuing numbers nothing holds. Exactly one attempt in each group is also made
live, because a file can name none or several, and a turn with no live node
disappears from the story.
One value is decided here rather than read from the file. Exactly one
attempt in each group is made live, because a file can name none or several,
and a turn with no live node disappears from the story.
"""
groups: dict[tuple[int, int], list[models.Action]] = {}
indices: dict[tuple[int, int], int] = {}
for spec in specs:
key = (spec["branch"], spec["depth"])
if key not in indices:
indices[key] = len(indices)
action = models.Action(
adventure_id=adventure.id,
branch_id=ids[spec["branch"]],
depth=spec["depth"],
index=indices[key],
type=spec["type"],
text=spec["text"],
reasoning=spec["reasoning"],
@@ -531,7 +514,6 @@ def _write_nodes(
live = next((row for row in rows if row.live), rows[0])
for row in rows:
row.live = row is live
attempts.renumber(rows)
def _write_memories(
+1 -1
View File
@@ -353,7 +353,7 @@ def build_context(
# Even the newest action alone is over budget: hard-truncate it.
included_actions.append(
models.Action(
adventure_id=action.adventure_id, index=action.index,
adventure_id=action.adventure_id,
type=action.type,
text=truncate_to_last_tokens(action.text, history_budget),
)
-24
View File
@@ -417,30 +417,6 @@ def newest(adventure: models.Adventure) -> models.Action | None:
return rows[0] if rows else None
def max_action_index(adventure: models.Adventure) -> int:
"""Highest `Action.index` in the adventure, story text or not. -1 if empty.
This is the only read in the module that is deliberately not scoped to a
path. `index` is a legacy column that remains unread until SP8 drops it. Its
one remaining job is to give the next row a number that no other row holds,
which is a fact about the adventure rather than about the story being
played. Scoping the query to a branch would let two branches issue the same
index.
"""
loaded = _loaded_actions(adventure)
if loaded is not None:
return max((a.index for a in loaded), default=-1)
db = _session(adventure)
if db is None:
return -1
highest = (
db.query(func.max(models.Action.index))
.filter(models.Action.adventure_id == adventure.id)
.scalar()
)
return -1 if highest is None else highest
def window_covering(
adventure: models.Adventure,
budget_tokens: int,
+104 -6
View File
@@ -23,7 +23,10 @@ import json
import re
from datetime import datetime
from sqlalchemy import inspect, text
from sqlalchemy import (
JSON, Boolean, Column, DateTime, Integer, MetaData, String, Table, Text,
inspect, text,
)
from sqlalchemy.engine import Engine
from . import compression, vectors
@@ -301,6 +304,34 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# turn streams. This is item S1 in `docs/self-review.md`. The table holds one
# row per user, so the rewrite is small and needs no VACUUM FULL.
(65, "ALTER TABLE settings DROP COLUMN stream"),
# Phase 17, SP8: drop the eight columns the story tree replaced. Each one was
# kept past the migration that superseded it so that a rollback found a real
# value on the rows the newer build wrote. The tree has run in production
# since Phase 14, so the rollback window is closed.
#
# `actions.index` was the global turn number. `depth` replaced it, and the
# last reader, the number issued to a new row, went with this migration.
# `variants` held the retry history as a repeating group, and `variant_index`
# and `variant_count` described that group's shape. Each attempt is its own
# row now, ordered by `id`. `state_before` and `world_state_before` recorded
# the state a node started from, which every attempt at a turn shares; the
# "after" columns record each attempt's own outcome instead.
# `adventures.memory_cursor` and `summary_cursor` were positions into a flat
# list, and the branch and depth pairs beside them replaced both.
#
# Dropping a column rewrites the toasted values on Postgres and nothing
# reclaims that space on its own. Run `VACUUM FULL actions;` on the direct
# Neon endpoint after the deploy, not the pooled one. It takes an ACCESS
# EXCLUSIVE lock, so the app blocks on `actions` while it runs.
(66, "ALTER TABLE actions DROP COLUMN variants"),
(67, "ALTER TABLE actions DROP COLUMN variant_index"),
(68, "ALTER TABLE actions DROP COLUMN variant_count"),
(69, "ALTER TABLE actions DROP COLUMN state_before"),
(70, "ALTER TABLE actions DROP COLUMN world_state_before"),
# `index` is a keyword in SQLite, so the column name is quoted.
(71, 'ALTER TABLE actions DROP COLUMN "index"'),
(72, "ALTER TABLE adventures DROP COLUMN memory_cursor"),
(73, "ALTER TABLE adventures DROP COLUMN summary_cursor"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -410,6 +441,8 @@ def _backfill_variant_count(conn) -> None:
in Python would read every stored attempt over the network once in order to
stop reading it on every request.
"""
if not _has_columns(conn, "actions", "variants", "variant_count"):
return
if conn.dialect.name == "sqlite":
sql = """
UPDATE actions SET variant_count = json_array_length(variants)
@@ -434,6 +467,22 @@ _ADD_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+\"?(\w+)\"
_DROP_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+DROP\s+COLUMN\s+\"?(\w+)\"?", re.I)
def _has_columns(conn, table: str, *columns: str) -> bool:
"""Returns `True` when `table` has every one of `columns`.
The data passes below read columns that later migrations drop. A pass only
ever has real work to do on a database old enough to still carry them, and
a `create_all` database is already current, so the guard skips the pass
rather than failing on a column that is not there. This is the same rule
`_column_already_there` applies to DDL, written for the passes.
"""
inspector = inspect(conn)
if table not in inspector.get_table_names():
return False
present = {col["name"] for col in inspector.get_columns(table)}
return all(column in present for column in columns)
def _column_already_gone(conn, sql: str) -> bool:
"""Returns `True` when `sql` drops a column the table no longer has.
@@ -589,6 +638,8 @@ def _backfill_tree(conn) -> None:
target being unset, so a run that fails partway through resumes rather than
applying twice.
"""
if not _has_columns(conn, "actions", "index"):
return
sqlite = conn.dialect.name == "sqlite"
# 1. A root branch per adventure. Its lineage names the row's own id, which
@@ -698,6 +749,8 @@ def _backfill_cursor_anchors(conn) -> None:
two forms return the same answer, because the rows the partition would
separate are the rows the filter removes.
"""
if not _has_columns(conn, "adventures", "memory_cursor", "summary_cursor"):
return
sqlite = conn.dialect.name == "sqlite"
story = _story_text_sql("text", sqlite)
for name in ("memory", "summary"):
@@ -750,6 +803,8 @@ def _backfill_state_after(conn) -> None:
SP4 is the first migration where `depth` and `index` can disagree, and it has
not run when this pass does.
"""
if not _has_columns(conn, "actions", "state_before", "world_state_before"):
return
for column, live in (
("state_after", "script_state"),
("world_state_after", "world_state"),
@@ -778,6 +833,39 @@ def _backfill_state_after(conn) -> None:
"""))
# The `actions` table as migration 60 finds it, declared here rather than read
# from `Base.metadata`. The split pass writes JSON values, and only a column
# type knows how to render a dict on this dialect, so it needs a Table. Reading
# the live one would tie a migration to the current model: migration 66 drops
# five of these columns, and the pass would then fail to compile on a database
# that still has them. This declaration is a snapshot of a past schema and must
# not be updated to track `models.py`.
#
# It carries its own `MetaData`, so `create_all` never sees it.
_ACTIONS_AT_60 = Table(
"actions", MetaData(),
Column("id", Integer, primary_key=True),
Column("adventure_id", Integer),
Column("index", Integer),
Column("branch_id", Integer),
Column("depth", Integer),
Column("live", Boolean),
Column("type", String(20)),
Column("text", Text),
Column("reasoning", Text),
Column("context_snapshot", compression.CompressedJSON),
Column("world_delta", JSON),
Column("state_before", JSON),
Column("world_state_before", JSON),
Column("state_after", JSON),
Column("world_state_after", JSON),
Column("variants", JSON),
Column("variant_count", Integer),
Column("variant_index", Integer),
Column("created_at", DateTime),
)
def _split_variants_into_siblings(conn) -> None:
"""Gives every discarded retry attempt its own row.
@@ -802,7 +890,9 @@ def _split_variants_into_siblings(conn) -> None:
The pass is resumable. A group that already has as many rows as its
`variant_count` claims has been split, so it is skipped.
"""
actions = Base.metadata.tables["actions"]
if not _has_columns(conn, "actions", "variants", "variant_index"):
return
actions = _ACTIONS_AT_60
last_id = 0
while True:
rows = conn.execute(
@@ -854,7 +944,8 @@ def _split_one_action(conn, actions, row, entries: list) -> None:
kept["world_state_after"] = live_world
# Use the Table rather than `text()`, here and below. These values are dicts
# bound for JSON columns, and the column type is the only thing that knows
# how to render one on this dialect.
# how to render one on this dialect. The table is `_ACTIONS_AT_60`, frozen
# at the schema this pass runs against.
conn.execute(actions.update().where(actions.c.id == row["id"]).values(**kept))
siblings = [
{
@@ -956,16 +1047,23 @@ def _set_version(conn, version: int) -> None:
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
def bootstrap(engine: Engine) -> None:
def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
"""Brings the database up to `through`, which defaults to the newest version.
The app always takes the default. A test passes an older version when it
asserts on something a later migration removes. A migration test that stops
at the version it is about keeps reading the columns that migration wrote,
rather than the schema those columns became several versions later.
"""
fresh = not inspect(engine).get_table_names()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
if fresh:
_set_version(conn, LATEST_VERSION)
_set_version(conn, through)
return
current = _get_version(conn)
for version, sql in MIGRATIONS:
if version > current:
if current < version <= through:
statement = _for_dialect(sql, conn.dialect.name)
# Skip the DDL when it has already run. The data pass below it
# still runs.
+9 -53
View File
@@ -118,15 +118,8 @@ class Adventure(Base):
# Phase 6: opt-in per adventure (extra AI calls)
auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False)
memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
# Legacy columns from Phase 6. Each holds a count of the actions that were
# folded into the memories or the story summary, expressed as a position in
# the story. Nothing has read them since SP3, and nothing writes them except
# a v1 import. They remain for one release so that a rollback resumes from a
# real number. SP8 drops them along with `actions.index`. The columns below
# hold the marks that this code actually uses.
memory_cursor: Mapped[int] = mapped_column(Integer, default=0)
summary_cursor: Mapped[int] = mapped_column(Integer, default=0)
# Phase 14, SP3: the same two marks expressed as coordinates. Each pair
# Phase 14, SP3: how far the memory pass and the summary pass have read,
# each as a coordinate. Each pair
# holds the branch and depth of the last action that pass covered. A
# position moves when an action in front of it is deleted, so the mark
# silently starts covering an action it never read. A depth is a coordinate
@@ -168,7 +161,7 @@ class Adventure(Base):
actions: Mapped[list["Action"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Action.index",
order_by="Action.id",
)
scripts: Mapped[list["AdventureScript"]] = relationship(
back_populates="adventure",
@@ -261,8 +254,7 @@ class Memory(Base):
)
# The stretch of story this memory summarizes, given as depths on
# `branch_id`. Both are NULL for a hand-written memory, which summarizes no
# actions. Before SP3 these columns held `Action.index` values, which were
# the same numbers. `source_end` is the depth of the node the memory
# actions. `source_end` is the depth of the node the memory
# attaches to, and `depth` below mirrors it. `source_start` is where the
# stretch begins, which is where the summarizer resumes if the memory is
# withdrawn.
@@ -283,10 +275,9 @@ class Memory(Base):
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this
# column current, for the same reason that `actions.variant_count` sits
# beside `actions.variants`. Readers need only the yes-or-no answer, and
# fetching six kilobytes of vector to get it is too expensive.
# Whether `embedding_blob` is set. `memorybank.set_vector` keeps this column
# current. Readers need only the yes-or-no answer, and fetching six
# kilobytes of vector to get it is too expensive.
embedded: Mapped[bool] = mapped_column(Boolean, default=False)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
@@ -346,18 +337,15 @@ class Action(Base):
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 one
# path rather than a global turn number. Node A4 and node B4 are
# alternatives, not duplicates. `depth` replaces `index` as the ordering
# key.
# alternatives, not duplicates.
#
# Both columns are nullable because ALTER TABLE cannot add a NOT NULL column
# without a default, and no default makes sense for a branch. The migration
# fills these columns for existing rows, and `tree.place_action` fills them
# for new rows. From SP2 onward, a NULL `branch_id` marks a row that no read
# can see. The legacy `index` column stays alongside, unread, until the tree
# is proven in production. SP8 drops it.
# can see.
branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
@@ -417,20 +405,6 @@ class Action(Base):
# and for re-attaching the emit block when replaying history to the model.
# Mirrors the active variant, same as text/reasoning/context_snapshot.
world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Legacy columns from before SP4. They hold `Adventure.script_state` and
# `Adventure.world_state` as they were immediately before this action's
# script hooks ran. Nothing has written or read them since SP4. The pair of
# "after" columns below replaced them, because each sibling attempt needs
# its own outcome and every attempt at a turn shares the same starting
# state. These columns remain for one release so that a rolled-back build
# still finds a real snapshot on the rows it wrote. SP8 drops them along
# with `index` and `variants`.
state_before: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
world_state_before: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# Phase 14, SP4: the shared script state and the RPG world state as they
# stood after this node was played. These columns record the node's outcome
# rather than its starting position.
@@ -453,24 +427,6 @@ class Action(Base):
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# A legacy column from before SP4. It holds the retry history as a repeating
# group inside a JSON list. Every attempt at a turn is now its own row, as
# described on `live` above and in `app/attempts.py`, so nothing reads this
# column. It remains until SP8 for the same reason `index` does. Migration
# 60 reads it once more, to turn each entry into a sibling row.
variants: Mapped[list | None] = mapped_column(JSON, nullable=True, deferred=True)
# Where the row sits in its sibling group. `variant_index` is this
# attempt's ordinal, counting from the oldest. `variant_count` is the number
# of attempts in the group. It is 0 rather than 1 when the turn was never
# retried, which the pager treats as having nothing to page through.
#
# Both columns are caches, and `attempts.renumber` is the only place that
# maintains them. The reason matches why `variant_count` once cached
# `len(variants)`: a page response needs both numbers for every row and
# cannot afford one query per turn. SP7 replaces the pager with the branch
# view, and SP8 drops both columns.
variant_count: Mapped[int] = mapped_column(Integer, default=0)
variant_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
-1
View File
@@ -206,7 +206,6 @@ def create_adventure(
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
index=0,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
+3 -10
View File
@@ -10,22 +10,15 @@ from sqlalchemy.orm import Session, undefer
from ... import attempts, memorybank, models, tree
from ...context import cursors
from ...context import history as context_history
from ...context import lineage
def next_index(adventure: models.Adventure) -> int:
return context_history.max_action_index(adventure) + 1
def next_depth(adventure: models.Adventure) -> int:
"""Returns the depth for the next node played onto this story, one past the tip.
This is not `next_index`, which returned the same number until SP5. `index`
has to stay unique across the whole adventure, because it is the v1 bundle's
key. On a story forked at depth 6 after twenty turns, `next_index` gives the
next node depth 21 and leaves a fourteen-deep gap in the path. A depth is a
position along this one story, and the branch is what makes it unambiguous.
A depth is a position along one story, and the branch is what makes it
unambiguous. Two branches each hold a node at depth 4, and they are
alternatives rather than duplicates.
"""
return adventure.head_depth + 1
+6 -14
View File
@@ -24,13 +24,10 @@ from ...context import lineage
# row.
ACTION_LIST_COLUMNS = (
models.Action.adventure_id,
models.Action.index,
models.Action.type,
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
models.Action.variant_count,
models.Action.variant_index,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
@@ -124,33 +121,28 @@ def annotate_takes(
This runs one query for the whole page rather than one per row. The pager
needs the shape of each turn's attempt group, and calling `attempts.group`
per action costs one query per message on screen. `variant_count` was cached
to avoid that cost, which is why SP8 could not drop it.
per action costs one query per message on screen.
This function reads the siblings rather than counting them. A group holds
only a few attempts, the page is bounded, and a count still needs a second
query for the ordinal. It fetches only the id and the ordering keys, so it
stays cheap even when the text is large.
query for the ordinal. It fetches only the id and the parent, so it stays
cheap even when the text is large.
"""
parents = {a.parent_id for a in actions if a.parent_id is not None}
if parents:
rows = (
db.query(
models.Action.id,
models.Action.parent_id,
models.Action.variant_index,
)
db.query(models.Action.id, models.Action.parent_id)
.filter(
models.Action.adventure_id == adventure_id,
models.Action.parent_id.in_(parents),
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
)
else:
rows = []
siblings: dict[int, list[int]] = {}
for row_id, parent_id, _ in rows:
for row_id, parent_id in rows:
siblings.setdefault(parent_id, []).append(row_id)
for action in actions:
ids = siblings.get(action.parent_id) if action.parent_id else None
+6 -10
View File
@@ -23,7 +23,7 @@ from ...sse import SSE_HEADERS, sse, turn_error
from ..settings import get_settings
from .deps import CurrentUser, current_adventure, router
from .nodes import _move_to_after, next_depth, next_index
from .nodes import _move_to_after, next_depth
from .paging import annotate_takes
@@ -275,11 +275,6 @@ async def _generate_turn(
reasoning = "".join(reasoning_chunks).strip() or None
ai_action = models.Action(
adventure_id=adventure.id,
# A sibling shares the turn's legacy index for the same reason it
# shares its depth: it is the same turn. Two rows then hold one index,
# which is safe, because `max_action_index` takes a maximum rather than
# a count, and nothing else reads the column.
index=retry_of.index if retry_of is not None else next_index(adventure),
depth=ai_depth,
type="ai",
text=text,
@@ -298,8 +293,10 @@ async def _generate_turn(
# a turn landed on top of it. See `memorybank`.
memorybank.forget_node(db, adventure, retry_of)
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
# Flush so the new attempt has an id. The session does not autoflush,
# and attempts page in id order, so a read taken before this point puts
# the newest attempt nowhere.
db.flush()
attempts.renumber(attempts.group(db, ai_action))
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
@@ -369,7 +366,6 @@ async def run_player_turn(
return
player_action = models.Action(
adventure_id=adventure.id,
index=next_index(adventure),
depth=next_depth(adventure),
type=payload.type,
text=modified,
@@ -384,8 +380,8 @@ async def run_player_turn(
db.refresh(player_action)
# The new action was added through its foreign key, so the loaded
# `adventure.actions` collection is stale. Without this expire,
# `build_context` and `next_index` for the AI action do not see the
# player action that was just saved.
# `build_context` for the AI action does not see the player action
# that was just saved.
db.expire(adventure, ["actions"])
yield sse({"type": "player", "action": action_json(player_action, db)})
if stop:
+2 -9
View File
@@ -186,27 +186,20 @@ class RefreshPlan(BaseModel):
class ActionOut(ORMModel):
id: int
adventure_id: int
index: int
type: str
text: str
reasoning: str | None = None
# Phase 12: the compact RPG state changes for this turn, read from the
# model property.
world_changes: list[dict] = []
# Retry history: how many attempts exist for this turn, where 0 means the
# turn was never retried, and which attempt is live. The attempts themselves
# come from `GET /actions/{id}/variants`, so this payload stays small.
variant_count: int = 0
variant_index: int = 0
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
# and which one is on screen. It is keyed on the parent, so it counts the
# attempts of this turn rather than every node that shares a depth, and it
# keeps counting them after one has been forked onto its own branch.
#
# A turn nobody has retaken reads 1/1, which is most turns, and the client
# draws no pager for a count of one. `variant_count` uses a different
# convention and reports 0 for the same case. Those two fields are the
# pre-SP9 pair, and SP8 drops them.
# draws no pager for a count of one. The attempts themselves come from
# `GET /actions/{id}/variants`, so this payload stays small.
take_count: int = 1
take_index: int = 0
# Which line this node is on, so the pager can distinguish the two kinds of
+11 -12
View File
@@ -118,8 +118,8 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
raise ValueError("cannot fork from a node that is not on a branch")
fork_depth = node.depth - 1
# Read the sibling attempts before moving the node. The session does not
# autoflush, so a later read still finds the node here and renumbers it back
# into the group it just left.
# autoflush, so a later read still finds the node here and hands the live
# flag back to it.
remaining = [
row for row in db.query(models.Action)
.filter(
@@ -127,7 +127,7 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
models.Action.branch_id == parent.id,
models.Action.depth == node.depth,
)
.order_by(models.Action.variant_index, models.Action.id)
.order_by(models.Action.id)
.all()
if row is not node
]
@@ -160,14 +160,12 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model
depth = node.depth
node.branch_id = new_id
node.live = True
node.variant_index = 0
node.variant_count = 0
# The group the node left needs a live attempt again. Taking the oldest is
# arbitrary, and it has to be somebody: a coordinate with no live attempt
# disappears from the story on the branch it was left on.
if remaining and not any(row.live for row in remaining):
remaining[0].live = True
for i, row in enumerate(remaining):
row.variant_index = i
row.variant_count = len(remaining) if len(remaining) > 1 else 0
adventure.head_branch_id = new_id
adventure.head_depth = depth
@@ -228,9 +226,10 @@ def place_action(
) -> models.Branch:
"""Puts `action` on the head branch and moves the head to it.
`depth` follows `index` while both columns exist. The two must agree,
because a read that orders by depth and a cursor that counts in index space
describe the same story, and SP2 swaps one for the other in a single step.
An action with no `depth` of its own goes one step past the tip, which is
where the next turn belongs. The opening of a new adventure lands at 0 that
way, because an adventure with nothing played has a head depth of
`NO_DEPTH`.
Pass `branch` when you have already resolved the head and are placing
several nodes at once. See `place_new_nodes` for why that is worth doing.
@@ -244,7 +243,7 @@ def place_action(
branch = branch or head_branch(db, adventure)
action.branch_id = branch.id
if action.depth is None:
action.depth = action.index
action.depth = adventure.head_depth + 1
if parent is not None:
action.parent_id = parent.id
elif action.parent_id is None and action.depth: