Score the plan against what the tree actually did

SP4 and SP5 are written down: what shipped, what it measured, and the
handful of things worth not rediscovering. The bug table at the top is
scored now that six of its seven rows are gone — and the seventh, the
one-turn holdback, is gone for a different reason than the one written
there, which is the correction that matters most. Editing a summarised
action is still unfixed and now says so; no subphase is scheduled for it.

The Open section closes. `retry_of.index` was the last item and SP4
answered it by reusing the retried node's depth.

Two vacuums are owed, SP1's and SP4's, and neither has deployed. One run
after the SP4 deploy settles both. SP8 grows two more columns to drop and
a caveat about which ones are not dead yet.

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 ffb2fd5b0e
commit d051501517
2 changed files with 252 additions and 30 deletions
+169 -5
View File
@@ -25,6 +25,21 @@ mutable list**:
It also resolves the 1NF violation: `variants` as a JSON repeating group becomes rows.
**Scored, 2026-08-18, with SP1–SP5 shipped.** Six of the seven are gone as described.
The seventh — the one-turn holdback — is gone too, but the reasoning above was wrong
about *why* it could go: siblings share a coordinate, so replacing what a turn says still
invalidates the memory covering it. What made it deletable is that the repair already
existed for undo and delete; see the trap note below. Two rows want a footnote:
- **Editing a summarised action** is still unfixed. The table says editing makes a new
node; nothing in SP1–SP5 makes it do that, and no subphase is scheduled to. `PATCH
/actions/{id}` still writes over the text in place, and the memory covering it goes
stale exactly as before. What *has* changed is that the machinery to fix it now exists
— an edit could write a sibling and switch to it, which is a retry the player typed —
so it is a small change whenever it is wanted.
- **The 1NF violation** is resolved in the database. The `variants` array survives in
exactly one place: the v1 export bundle, which SP6 replaces.
## Design decisions (settled 2026-08-16)
- **Full branching, with UI.** Not the "tree schema, no branch picker" middle option —
@@ -107,10 +122,10 @@ number — `A4` and `B4` are alternatives, not duplicates.
## Open
- **`retry_of.index` reuse** stops the world-state cooldown clock advancing on a re-run of
the same turn. Whatever replaces it must preserve that. (Carried into SP5 below.)
Everything else that stood open here was decided on 2026-08-17 — see the next section.
Nothing. The last item — **`retry_of.index` reuse**, which stopped the world-state
cooldown clock advancing on a re-run of the same turn — was closed in SP4 by reusing the
retried node's *depth*, and pinned by a test in SP5. Everything else that stood open here
was decided on 2026-08-17; see the next section.
---
@@ -161,6 +176,13 @@ even there only where the change is deliberate and named below.
SP3 as `memorybank.settled_after`, which is the `- 1` in "how much story is past the
mark"; that subtraction is the whole of it.
*Closed in SP4, but not for the stated reason.* Siblings share a coordinate and the
mark names the coordinate, so replacing what a turn says still invalidates the memory
covering it — retry mutating a row was never the whole of the problem. What let the
holdback go is that the right repair (`forget_node` plus a rewind) already existed for
undo and delete, and retry and a sibling switch now make it too. **If a mark still
needs correcting when the story changes, correct it; do not decline to make the mark.**
## Subphases
Each ships independently, on its own branch, green before the next starts.
@@ -437,6 +459,68 @@ results, including that the gold script is not double-applied. `test_state_rever
against after-snapshots. Migration test: attempt count preserved, the active attempt
becomes the head.
**Done, 2026-08-18** (branch `sp4-sibling-nodes`). **347 tests green**, the 330 SP3
finished with plus 13 in the new `test_attempt_siblings.py`, 8 in `test_tree_migration
.py`, and three holdback tests deleted. `app/attempts.py` is the new module; migrations
57–60 add `live`, `state_after`, `world_state_after`, derive the after-snapshots, and
split every `variants` list into rows.
**The baseline test did not have to move, and neither did `test_retry_variants.py`.**
Both pass unmodified. SP4 was *permitted* to change the variant-count semantics and it
turned out nothing observable needed changing — which is the strongest form the pass
condition could have taken, and worth knowing before SP6 asks for the same licence.
Seven things worth not rediscovering:
- **A coordinate needs a `live` flag, and the branch clause is where it belongs.**
Siblings share `(branch_id, depth)`, so the lineage clause alone returns all of them
and the story tells itself twice. `Path.clause` adds `Action.live` for actions (and
only for actions — memories have no siblings to lose to), which means no read of the
story had to learn that retries exist. `app/attempts.py` is the only code that looks
past it.
- **The prompt has to move with the flag or retry becomes a storage multiplier.** A
`context_snapshot` is ~163 kB of prompt that every attempt at a turn shares, and the
old JSON list existed precisely to store it once. Giving each sibling row a copy would
have undone that. So the invariant is: the assembled prompt lives on the attempt the
story tells, and a superseded one keeps only its own slices (`ATTEMPT_KEYS` — the
world-state delta, the script report, the raw reply). Measured on the pre-tree
600-action fixture, migrated: **700 rows for the same 600-turn story, and the prompt
archive byte-identical at 0.50 MB.**
- **The holdback was not quite unnecessary — it was the wrong repair.** The plan said
retry stops mutating rows so nothing goes stale. Not so: siblings share a coordinate,
and the mark and the memory both name the *coordinate*, so replacing what a turn says
still invalidates them. What made the holdback deletable is that the right repair
already existed — `forget_node` plus a rewind, which undo and delete have called since
SP3. Retry and a sibling switch now call it too, and the holdback (`settled_count`,
`settled_after`, `settled_story_actions`, `newest_settled`) is gone. **A memory can
now cover the newest action**, which it never could before.
- **`state_after` needed the same flush guard `place_action` has.** The migration
derives every existing row's outcome from the next row's `state_before`, and the tip's
from the adventure's live state — but a row written by a fixture or a script after
that has nobody to derive from, and the failure mode is undo silently leaving the
scoreboard where it was. `tree.stamp_outcome` runs from `before_flush` beside
`place_new_nodes`. It writes the truth as of the flush: a writer that changes no state
between two nodes leaves the same state behind both.
- **Switching attempts hands the caller a different row id**, because that is what an
attempt being a node *means*. The endpoints are addressed by any attempt at the turn
rather than by the live one, so a client holding a stale id still asks about the right
turn — but `Play.jsx` matched the reply against `updated.id` and had to be changed to
match against the action it asked about. One line, and SP7 removes the pager anyway.
- **Deleting a turn deletes its attempts.** A discarded attempt is only reachable
*through* its coordinate, so leaving it behind would leave a row nothing can name and
no read can see. `delete_turn` is that rule in one place, called by undo and by
delete-an-action, and it works whichever attempt's id the caller happens to hold.
- **Siblings share the legacy `index`.** They are takes on one turn, so two rows now
carry one index — which `max_action_index` (a maximum, not a count) survives, and
which is what lets the v1 export fold a group back into a `variants` array. Export is
now the only producer of that shape anywhere; nothing in the database holds one.
Measured: index **1.8 kB**, page load **62.7 kB**, one turn **734.8 kB** — the first two
byte-identical to SP1's and SP3's, the turn up 1.0 kB (0.14 %) for the `live` column
across a 346-row read. Migrations 57–59 each rewrite every row of `actions` and 60
inserts one per discarded attempt, so **this deploy owes a `VACUUM FULL actions;`** —
the SP1 one is still owed too, and one vacuum after this deploy settles both.
### SP5 — Fork on continue
Playing past a non-head sibling promotes it: a `branches` row with `parent_branch_id`,
@@ -449,6 +533,74 @@ correct and capped at each `fork_depth`; both branches read independently; switc
restores the right script/world state; the cooldown test from `test_worldstate.py`
still holds across a retry.
**Done, 2026-08-18** (branch `sp5-fork-on-continue`). **365 tests green**, the 347 from
SP4 plus 18 in `test_branch_forking.py`. `tree.fork` is the whole of it; three endpoints
(`GET /branches`, `POST /branches/{id}/switch`, `POST /actions/{id}/fork`) are what SP7's
tree view will be drawn on.
**Where the promotion happens, and why not where the plan said.** The plan put it on the
*next turn*: attempts stay leaves, and one becomes a branch when a turn is played past
it. Promoting the winner then means moving a row off the branch the reader is standing
on and leaving that branch to pick a new node for the depth — it disturbs a story nobody
asked to change. The same divergence, seen from the other side, promotes the attempt you
are *leaving for*: `POST /actions/{id}/fork` gives the discarded attempt a branch and
moves the head to it, and the line it leaves is untouched. Branch count is identical
either way — one per divergence somebody actually built on — and one of the two never
rewrites a story in place. Without it, the losing attempts would also be unreachable
forever, since only the tip can be switched: fork-on-continue alone is a one-way door.
Six things worth not rediscovering:
- **A fork must not move the derived work, and it was about to.** The first cut moved the
memories at the forked coordinate onto the new branch and re-anchored the cursors that
named it. Both are wrong, and for the same reason: a memory describes whichever attempt
was *live* at that coordinate, which is the one staying on the parent. The right answer
needs no code at all — the lineage caps the parent at `fork_depth`, one depth short of
it, so the memory is simply out of range from the fork, invisible to both the retrieval
clause and `Path.depth_on`. The block is summarized again, from the text this branch
actually tells.
- **Depth had to stop following `index`.** They agreed until now. `index` is
adventure-wide (the v1 bundle is keyed on it) so on a story forked at depth 6 after
twenty turns it hands the next node depth 21 and leaves a fourteen-deep hole in the
middle of a path — which every windowing estimate then has to work around.
`next_depth` is `head_depth + 1`; `place_action` still derives depth from index for
fixtures and imports, which is what that default is for.
- **Undo has to stop at the fork.** It reads the newest two nodes on the path and deletes
the turn they make up — and on a fresh branch the second of them is borrowed from the
parent, whose story also contains it. The guard is on the row's own `branch_id`, not on
the fork depth, because that is the fact that decides it.
- **The cooldown clock came out right for free.** It lives in `_meta.last_changed` inside
the world state, and the world state is restored from the tip's `world_state_after` on
every switch — so each branch carries its own clock without anything knowing there is
one. The carried-over open item (a retry must not advance it) is SP4's reused depth,
and both are pinned by tests.
- **The session does not autoflush**, and `fork` read the sibling group after moving the
node out of it — so the move had not been written and the node was renumbered straight
back into the group it had just left. Read the group first. (`autoflush=False` is
deliberate, in `database.py`; anything in this phase that mutates then queries the same
rows has to order itself by hand.)
- **`/fork` has to be idempotent before it is anything else**, because a fork leaves the
promoted attempt alone on its branch: a repeated call — a double click, a retried
request — would otherwise be told the turn it just forked has nothing to fork to. The
"already the story" case is answered before the shape of the turn is looked at.
Measured, on a 40-turn story forked **twenty** times against the same story flat:
21 branches, 140 rows, an 80-action story, and a page load of **31,652 B against
31,433 B (1.007×)**. A branch costs **103 B** of id, parent, fork depth and cached
ancestry. No migration, no vacuum.
**One gap, deliberately left for SP6.** A forked adventure has no honest `v1` export —
the format has one story and there are two — so export emits every branch's turns
interleaved by index, which reads as a mangled story rather than as lost data. SP6's v2
bundle fixes it, and SP7 is where a player first gets any way to fork at all, so the
order those two ship in is the order that matters.
**Also known, and not fixed here:** the two cursors are one pair on the adventure, so
switching branches makes the mark on the branch being left unreadable from the new one
(`Path.depth_on` answers "nothing covered", which is the safe direction — redo the work,
never skip it). Switching back and forth therefore re-summarizes. Per-branch cursors are
the fix if it ever matters; it costs AI calls, not correctness.
### SP6 — Export/import v2
`ai-dnd-adventure-v2` carries branches and nodes; import accepts v1 and v2, mapping a v1
@@ -475,6 +627,16 @@ Only once the tree is proven live. Migration drops `index`, `variants`, `variant
and `summary_cursor`** — unread since SP3, kept only so a rolled-back build resumes from
a real number. They are on `adventures`, so dropping them costs no vacuum.
**And `actions.state_before` / `world_state_before`**, unwritten and unread since SP4 for
the same reason: a rolled-back build still finds a real snapshot on every row it wrote
itself. They are deferred JSON on `actions`, so they go in the same rewrite as `index`
and cost nothing extra.
`variant_count` and `variant_index` are the two to check before dropping: SP4 left them
as a maintained cache of the sibling group's shape, because the pager reads both for
every row of a page and must not pay a query per turn to get them. They are dead only
once SP7's tree view has replaced the pager.
**Verify:** full suite; egress ceilings; a measured before/after size, aggregates only.
## Standing constraints
@@ -484,4 +646,6 @@ a real number. They are on `adventures`, so dropping them costs no vacuum.
fixture. If a real Postgres is ever needed for a write path, it is a throwaway
database whose name contains `stress`/`scratch`, and it is asked for first.
- **Any migration that rewrites `actions` is followed by `VACUUM FULL actions;`** on the
direct endpoint, not `-pooler`. SP1, SP4 and SP8 each rewrite every row.
direct endpoint, not `-pooler`. SP1, SP4 and SP8 each rewrite every row. **As of
2026-08-18 two are owed** (SP1's and SP4's) and neither has been deployed; one vacuum
after the SP4 deploy settles both.