e126bda387280a1e4386bdc123046289ec24d15a
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6fa213f6db |
Group a turn's takes by their parent, not by where they sit
SP7 shipped a tree nobody could use. Driving it by hand found three things, and the schema is what the third one needs. A take forked onto its own branch leaves the coordinate its siblings are still at. `attempts.group` filtered on (branch_id, depth), so that take read as the only one of its turn -- 1/1 where the player is owed 1/3, with the other takes unreachable from the line they were taken on. Nesting has the same shape from the other side: takes under C1 and takes under C2 share a depth, and only the parent says a pager under C2 reads 2/2 rather than counting C1's three too. So `actions.parent_id`, and `group` keys on it. The alternative -- pointing a branch's fork at a node instead of a depth, so a promoted take never moves -- was rejected: `lineage` exists so a read is an OR-clause per branch rather than a walk up parent pointers, and moving the fork point changes path resolution itself, which drags in cursors, memory depths and both bundle formats. This column is read to group takes and for nothing else. One indexed lookup, never a walk, and no read of the story changes. Two writers had to learn it. `add_attempt` places a sibling by hand rather than through `place_action`, and copies the parent, because a take belongs to the turn it is a take *of*. `place_action` resolves it from the path for a genuinely new node, which is the honest default. And one reader had to be kept out of it. `delete_turn` meant "every attempt at this coordinate"; the group spans branches now, so undoing a turn would have deleted a take that another branch is telling. `attempts.on_branch` scopes it back. 409 tests, unchanged from main and green -- a linear story has one take per parent, so none of this is reachable until a second one exists. The migration adds one column, backfills the linear case, and leaves NULL where it cannot honestly place a row; `group` falls back to the coordinate there, which is the rule those rows were written under. One `VACUUM FULL actions;` owed after deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |
||
|
|
2d38a162d4 |
Give every memory a node, and show only the ones on your path
A hand-written memory used to carry a NULL depth, described in the model as "belongs to the adventure rather than to a path". That sounds harmless and is not: a NULL is a coordinate no fork can cap, so a note typed on one line followed the reader onto branches whose story it never described. It takes the head now — the story you were reading when you wrote it — and obeys exactly the rule a summarised memory obeys. The unanchored escape clause in lineage.Path.clause existed for that single case and is deleted rather than left unused. Its docstring argued that a capped depth would drop a typed memory the moment its branch stopped being the newest entry; anchoring answers the same worry better, because the memory is not exempt from the path, it is on one. The drawer now shows the path being read and nothing else, filtered by the clause retrieval itself uses, so the bank you can see is the bank the model can see. Nothing is stranded: a memory lives on a branch, switching to that branch shows it, and deleting the branch deletes it. Pinning decides order, the path decides existence. Migration 62 lands existing NULL-depth memories at depth 0 of their branch rather than at the tip. 0 is at or before every fork point, so every memory stays visible from exactly the paths it is visible from today — nobody's bank loses a row on deploy. The tip is the tidier-sounding choice and would have emptied them out of every branch forked earlier than they were typed. This supersedes the on_path flag and the "another branch" badge from earlier today; anchoring makes them redundant, and they are removed. Four tests changed because they asserted the old contract, not because they broke. The one worth reading is the pair replacing test_a_hand_written_memory_is_not_lost_at_the_first_fork: typed on shared trunk it still survives a fork, and typed on ground the fork never travelled it no longer follows you. 402 tests. Verified on tools/branch_fixture.py: each branch's drawer holds its own memory and not the other's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g |
||
|
|
ffb2fd5b0e |
Let a story go two ways at once
Attempts pile up at the tip as leaves and cost nothing. The moment the
player takes the story down one the line has already moved past, the two
futures have to coexist — so `tree.fork` gives that attempt a branch of
its own, forked at the depth just before it, and the line it leaves
keeps every turn it has.
One row is inserted and one row is moved. Nothing is copied: everything
before the fork is borrowed through the lineage cached on the branch
row. Measured on a 40-turn story forked twenty times — 21 branches, 140
rows, an 80-action story — the page load costs 31,652 B against the
31,433 B the same story flat costs, and a branch is 103 B of ancestry.
Nothing derived moves either, and that is the part worth keeping: a
memory hangs off the coordinate the parent's attempt still occupies, and
the lineage caps the parent one depth short of it. The fork simply
cannot see it, so it resummarizes that ground from the text it actually
tells, without a line of bookkeeping.
Two things had to change underneath. A new node's depth now comes from
the tip of its branch rather than from the adventure-wide `index`, which
would have left a hole in a fork's path the width of the other branch.
And undo stops at the fork — the turns before it belong to the branch
this one grew out of.
`GET /branches`, `POST /branches/{id}/switch` and
`POST /actions/{id}/fork` are the endpoints SP7's tree view is drawn on.
365 tests green, 18 of them new in test_branch_forking.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
|
||
|
|
0a12d9cd47 |
Make a retry a node, not a rewrite
Every attempt at a turn is now its own row at the same (branch, depth), with `live` naming the one the story tells. The JSON repeating group on `actions.variants` is read one last time, by a migration that writes it out as the sibling rows it always described, and then goes unread. The snapshots turn around with it: an action carries the state it left behind rather than the state it started from, because attempts at one turn share a starting position and differ exactly in their outcome. Rolling back is "what the node in front left behind", one lookup on the path, and it is what undo and retry now both read. And the memory holdback goes. It existed because retry rewrote a row under a mark that had already moved past it; a retry writes a sibling now, and replacing what a coordinate says withdraws what was derived from it — the same repair undo and delete already made. The assembled prompt is still stored once per turn: it moves with the live flag, so a superseded attempt keeps only the few hundred bytes that were its own. Measured on the 600-action fixture: 700 rows for the same 600-turn story, prompt archive byte-identical at 0.50 MB, index 1.8 kB and page load 62.7 kB unmoved. 347 tests green. `tests/test_story_tree_baseline.py` and `tests/test_retry_variants.py` pass unmodified — SP4 was allowed to move the baseline for the variant-count semantics and did not need to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |
||
|
|
c51531709d |
Mark the story with a node, not with a count
The memory bank and the story summary each kept a cursor: how many story actions they had already covered. A count is a position in a list, and this list moves — delete an action in front of the mark and every later one slides down a slot, so the mark now covers one it has never read. All the cursor bookkeeping existed to patch that up. Both marks are now (branch_id, depth): the node up to and including which the work is done. A depth is a coordinate along a path, not an offset into a list, so nothing in front of it can move it. That deletes rather than rewrites `position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`, `prune_dangling_memories` and the every-pass clamp in `run_post_turn`. A memory hangs off the node its block ends on, so a fork inherits its ancestors' memories without copying any, and retrieval selects through the branch clause over the *whole* lineage — recall is long-range by definition and cannot be windowed. Measured: 1,807 B on a story forked twenty times against 1,823 B on a flat one of the same length. Migrations 53-56 translate the old counts into nodes. They rewrite `adventures` and not `actions`, so this one needs no VACUUM FULL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |
||
|
|
05a2a77e4c |
Resolve the head once a flush, not once a node
The identity map holds weak references. A branch row nobody keeps a strong reference to is collected between two nodes, so resolving the head inside the placement loop read it back from the database for every node in the flush: 201 SELECTs on `branches` to write 200 actions, and 36 s -> 45 s on the same 297 tests. Nothing about any result changed, which is why only a stopwatch found it, and why there is now a test counting the reads. Also: the two reads left un-pathed on purpose say so where they live — `max_action_index` allocates the legacy `index` and must stay adventure-wide or two branches issue the same number, and export is a flat v1 bundle whose reader has no idea branches exist. And `Adventure.actions` keeps its `index` ordering, because ordering the collection by depth would not make it a story: it is every branch's actions, and a path is a selection out of it. 318 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |
||
|
|
563b9af9cf |
Read one story, and know which one
Every read of an action now goes through a single module. `context/lineage.py` turns a branch's stored lineage into the OR-of-ranges that is "this story", and history, paging, the newest-action lookups, the index screen and the scripting history API all select through it. A forgotten clause does not raise — it quietly assembles a page, or a prompt, out of two different stories — so the clause lives in one place rather than in a convention. The read that mattered most was the shortcut: `_from_memory` sliced `adventure.actions`, which is every branch's actions, not the path. It now cuts the loaded collection down with the same predicate the SQL uses. Same trap one layer up, and user-visible: `pipeline._history()` hands user scripts the story, and was handing them the collection. Tail reads window the lineage as well as the rows: the newest few entries cover the context budget, so a story forked twenty times reads its tail with one clause and costs 1.07x what an unforked story of the same length costs. The estimate is depth arithmetic, and where a deleted action leaves a gap the read notices it came up short and widens to the whole ancestry. Ordering moves from `index` to `depth`, with `id` breaking ties. The two hold the same numbers until retry stops mutating rows in SP4, but only one of them is a position along a path. One thing SP1 did not anticipate: wiring the writers was not enough. From here a row without a branch is a row no read can see, and "every writer remembers" has to hold for every fixture, script and test ever written — including the SP0 baseline, which writes its actions straight to the database and must pass unmodified. So the session enforces it: `tree.place_new_nodes` runs from before_flush and places anything unplaced. The call sites keep their explicit calls, because a node placed at the call site is placed before the code around it reads it back. 316 tests green: the 297 from SP1, plus 19 in test_branch_clause.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |
||
|
|
d3756abdaa |
Give every action a branch and a depth
Phase 14 SP1. The tree goes into the schema and nothing reads it yet: a `branches` table, `branch_id`/`depth` on actions and memories, a head pointer on adventures, migrations 46-52, and a server-side backfill that re-reads every existing adventure as a tree with one branch. `depth` holds the number `index` already held, gaps included, so no story changes — a linear story *is* a tree with one branch, which is what makes the SP0 baseline passing unmodified the pass condition rather than a hope. The writer had to come with it. No migration will ever visit a row written after it ran, so columns backfilled today and populated next subphase would leave a hole exactly the width of one deploy, and from SP2 on a row without a branch is a row no read can see. `app/tree.py` owns that: one module, because a node written without a branch fails by disappearing rather than by raising. Three things the schema itself insisted on: - `adventures.head_branch_id` is a plain integer, not a foreign key. Pointing both ways makes the two tables a cycle create_all cannot order, and its escape hatch needs an ALTER SQLite does not have. It is a cache, and a head naming a branch that is gone recovers onto the root. - `lineage` is NOT NULL, so the backfill inserts `'[]'` and fills it in a second pass guarded on `json_array_length(lineage) = 0` — not `= '[]'`, because Postgres `json` has no equality operator. - SQLite will not drop a column a foreign key names, which is how two existing tests broke: they simulated an old database by rewinding the stamp while leaving the new columns in place. Every ADD COLUMN migration is now idempotent, and `tests/test_tree_migration.py` builds a genuine schema 45 by rebuilding three tables from frozen DDL so the real ALTERs run. 297 tests green, 14 of them new. `branches` costs 0.1 kB of a 733.5 kB turn; page load and index are byte-identical to the recorded figures. The deploy that ships this needs one `VACUUM FULL actions;` on the direct endpoint afterwards — it rewrites every row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7 |