Answer the review, and keep the opening node's bank

Nine findings from a review of the phase-14 stack. The one about a retry
withdrawing a memory is not a bug — a memory anchored to a node describes that
node, and it goes when the node goes. The root is the exception, and it is the
only one: migration 62 parked every memory written before memories had
coordinates on depth 0, so withdrawing the opening node would retire a whole
bank nobody attached there. A memory with no source range covers no story and
now stays; a summary that genuinely ends there is still withdrawn.

The rest are repairs.

* The adventure list quoted whichever attempt was written last rather than the
  one the story tells, so switching back left the index disagreeing with the
  page.
* A v1 import gave a typed memory no depth, rebuilding the NULL the migration
  exists to remove — invisible until the imported adventure forked.
* The action cap counted a v1 file's turns, and a turn expands into a row per
  saved attempt, so a file inside the cap could write a multiple of it.
* Forking a live node on a borrowed ancestor promoted a sibling on a branch the
  caller never named. It is a branch switch, and now says so.
* Switching attempts left the state, status and memory panels reading the
  previous take: the story does not change length, so nothing keyed on its
  length noticed. Same class as the branch-switch bug this phase already fixed.
* A retry after switching back numbered the new attempt into the middle of the
  group instead of the end.
* The cursor backfill numbered every action in the table once per adventure;
  correlated to the adventure being updated, it is an index lookup instead.
* Renaming a branch answered own_actions=0.

And one behaviour change recorded rather than repaired: script-visible history
and actionCount no longer count blank-text rows. That is the right shape and
there is no reading compatible with both, so plan/14 says so.

409 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 2d38a162d4
commit 4af6e17406
14 changed files with 330 additions and 17 deletions
+11 -1
View File
@@ -150,7 +150,17 @@ def add_attempt(
replacement.branch_id = previous.branch_id
replacement.depth = previous.depth
replacement.live = True
replacement.variant_index = previous.variant_index + 1
# The end of the group, not one past `previous` — which is only the same
# thing when `previous` is the newest take. Switch a three-take turn back to
# take 1 and retry, and `previous.variant_index + 1` collides with take 2;
# `renumber` then breaks the tie by id and files the new attempt *between*
# takes 2 and 3, so the pager walks the takes in an order they were not made
# in. `group` is 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,
)
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
+10
View File
@@ -545,6 +545,16 @@ def _write_memories(
# last action it summarises, which on one branch is that node's depth.
if memory.depth is None and memory.source_end is not None:
memory.depth = memory.source_end
# A v1 memory that summarises nothing — one the player typed — has no
# depth to derive, and leaving it NULL here would rebuild by import the
# exact state migration 62 exists to end: `Path._entry_clause` compares
# `depth <= max_depth`, which a NULL fails, so the memory would vanish
# from every branch the moment the imported adventure was forked. The
# root is the same answer the migration gives, and for the same reason
# — 0 is at or before every fork point, so it is visible from every
# path this adventure can grow.
if memory.depth is None:
memory.depth = lineage.ROOT_DEPTH
db.add(memory)
+8
View File
@@ -41,6 +41,14 @@ from .. import models
# separately so a read never has to import the write half.
NO_DEPTH = -1
# The opening node of an adventure. Depth 0 exists only on the root branch — a
# fork starts its own nodes after the depth it forked at — so this names one
# node per adventure, not one per branch. It is also where migration 62 parked
# every memory written before memories had coordinates, which is why the two
# places that can retire a memory (`memorybank.forget_node`, and a v1 import
# with no depth to read) both have to say something about it.
ROOT_DEPTH = 0
def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
"""`branch.lineage` as (branch_id, max_depth) pairs, newest first.
+14
View File
@@ -171,6 +171,9 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
the node before it, which is a depth whether or not anything still sits
there.
The opening node is the one exception, because migration 62 parked the
whole pre-coordinate bank on it — see the comment on `lineage.ROOT_DEPTH`.
Returns how many memories were withdrawn.
"""
if action.branch_id is None or action.depth is None:
@@ -184,6 +187,17 @@ def forget_node(db: Session, adventure: models.Adventure, action: models.Action)
)
.all()
)
if action.depth == lineage.ROOT_DEPTH:
# The opening node is special, and only for memories that describe no
# stretch of story. Migration 62 parked every memory written before
# memories had coordinates at depth 0 — that was the choice that took
# nothing away from anybody, but it also collected them all onto one
# node, so withdrawing that node would retire a player's whole bank in
# a single click. A memory with no `source_start` was typed (or
# migrated), describes nothing that can fall off the end, and so has
# nothing to be withdrawn *from*: it stays. A summary that genuinely
# ends here is still withdrawn, because the text it describes is going.
doomed = [m for m in doomed if m.source_start is not None]
if not doomed:
return 0
starts = [m.source_start for m in doomed if m.source_start is not None]
+17 -5
View File
@@ -585,6 +585,17 @@ def _backfill_cursor_anchors(conn) -> None:
Guarded on `_depth = -1` so a run that dies halfway resumes: every
adventure this has already converted is skipped, and one it has not is
indistinguishable from an untouched row.
**The row numbering is correlated, not ranked-then-filtered.** Numbering
every action in the table and picking one row out of the result reads the
whole of `actions` per adventure — the window function is what stops the
correlation being pushed down, so the planner has no way to make it cheaper
— and this runs inside the one transaction that holds the schema, at boot,
against a database with real stories in it. Restricting the scan to the
adventure being updated makes each pass an index lookup on
`actions.adventure_id` instead, and `PARTITION BY` is then a partition of
one. The two forms give the same answer for the same reason: the rows the
partition would have separated are exactly the rows the filter removes.
"""
sqlite = conn.dialect.name == "sqlite"
story = _story_text_sql("text", sqlite)
@@ -594,13 +605,14 @@ def _backfill_cursor_anchors(conn) -> None:
SET {name}_cursor_branch_id = {_root_branch_of('adventures.id')},
{name}_cursor_depth = COALESCE(
(SELECT ranked.depth FROM (
SELECT adventure_id, depth, ROW_NUMBER() OVER (
PARTITION BY adventure_id ORDER BY depth, id
SELECT depth, ROW_NUMBER() OVER (
ORDER BY depth, id
) AS rn
FROM actions WHERE {story}
FROM actions
WHERE {story}
AND actions.adventure_id = adventures.id
) AS ranked
WHERE ranked.adventure_id = adventures.id
AND ranked.rn = adventures.{name}_cursor),
WHERE ranked.rn = adventures.{name}_cursor),
(SELECT MAX(a.depth) FROM actions a
WHERE a.adventure_id = adventures.id
AND {_story_text_sql('a.text', sqlite)}),
+46 -6
View File
@@ -203,6 +203,13 @@ def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict
models.Action.adventure_id.in_(list(head_branches)),
models.Action.branch_id.in_(branch_ids),
models.Action.type.in_(NARRATION_TYPES),
# Siblings share a depth and the newest of them has the highest id,
# so without this the snippet quotes whichever attempt was written
# last rather than the one the story tells. Switching back to an
# earlier take would leave the index screen quoting the discarded
# one — the story on the screen and the story in the list would
# disagree, and only the list would be wrong.
models.Action.live.is_(True),
)
.subquery()
)
@@ -1150,14 +1157,17 @@ def rename_branch(
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(branch)
tip = (
db.query(func.max(models.Action.depth))
# Both numbers in the one pass, and counted the way `list_branches` counts
# them — live rows on this branch. A renamed branch is the same branch, so
# the row this hands back has to be the row the panel would have fetched.
tip, own = (
db.query(func.max(models.Action.depth), func.count(models.Action.id))
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch.id,
models.Action.live.is_(True),
)
.scalar()
.one()
)
return schemas.BranchOut(
id=branch.id,
@@ -1166,7 +1176,7 @@ def rename_branch(
depth=tip if tip is not None else (
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
),
own_actions=0,
own_actions=own,
is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at,
@@ -1342,8 +1352,26 @@ def fork_from_attempt(
# attempt alone on its branch: a client that repeats the call — a double
# click, a retried request — must get the same answer, not a complaint that
# the turn it just forked has nothing to fork to.
if action.live and action.branch_id == adventure.head_branch_id:
return current_window(db, adventure)
if action.live:
# A live node already *is* what its coordinate says, so there is no
# attempt here to take. On the path being read that is simply a no-op,
# and it has to stay one: a client that repeats the call — a double
# click, a retried request — must get the same answer, not a complaint
# that the turn it just forked has nothing to fork to. Off the path it
# is a different line's story, and moving there is a branch switch.
#
# The membership test is the whole lineage, not `head_branch_id`. A
# head borrows its ancestors' turns, so a live node on an ancestor is
# already being read; forking it would move the live row off the parent
# and promote a sibling in its place, rewriting the story on a branch
# nobody asked about *and* on this one, which borrows that depth.
if lineage.path_of(db, adventure).contains(action):
return current_window(db, adventure)
raise HTTPException(
400,
"That take is already the story on another branch. Switch to that "
"branch to read it.",
)
if len(attempts.group(db, action)) < 2:
raise HTTPException(
400, "This turn has only one take, so there is nothing to fork to."
@@ -1482,6 +1510,18 @@ def import_adventure(
# disagrees with itself is a 400 and not a half-imported adventure holding a
# story with a hole in it.
story = bundle.plan(payload, version)
# Counted again, on what will actually be written. The check above reads the
# file's own lists, and in a v1 file a turn is one entry carrying its retries
# in a `variants` array — which `plan()` expands into one row per attempt
# (SP4 made every attempt a node). So a file of 5,000 turns with ten takes
# each passes a 5,000-action cap and writes 50,000 rows, comfortably inside
# the 20 MB body limit. `plan()` is pure and the adventure does not exist
# yet, so this still costs nothing but the planning.
limits.check_bundle_lists(
actions=story["nodes"],
memories=story["memories"],
branches=story["branches"],
)
# Raw-dict import bypasses the schemas — clamp strings headed for VARCHAR
# columns (Postgres enforces the widths; see schemas.py).
+9
View File
@@ -28,6 +28,15 @@ class ScriptPipeline:
# actions, and this is the documented history API a user script reads.
# Handing a script the siblings of the turn it is running on would be
# the same bug as building a prompt from them, only user-visible.
#
# `story_actions` also drops blank-text rows, which `adventure.actions`
# kept, so this array is shorter than it used to be for an adventure
# that has any — and `info.actionCount` counts the same way. That is
# deliberate: a row with no text is this app's bookkeeping, it has no
# counterpart in the AI Dungeon history a ported script was written
# against, and the prompt has never included one. A script keyed on
# "every N actions" will land on different turns than it did before
# phase 14; there is no reading of this that is compatible with both.
return [
{"text": a.text, "rawText": a.text, "type": a.type}
for a in context_history.story_actions(self.adventure)