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:
committed by
Parth
co-authored by
Claude Opus 5
parent
e0bf2b61d9
commit
f1bebe18d0
+15
-37
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user