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
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
c7b6a46a8a
commit
c51531709d
@@ -157,7 +157,9 @@ even there only where the change is deliberate and named below.
|
||||
is far shorter. The cap has to count the tree but be explained as the tree, or move.
|
||||
- **The holdback cannot die in SP3.** `settled_story_actions` exists because retry
|
||||
mutates a row in place. Retry stops mutating in SP4, so the holdback is only safe to
|
||||
delete there — deleting it in SP3 reopens the exact bug it was written for.
|
||||
delete there — deleting it in SP3 reopens the exact bug it was written for. It survived
|
||||
SP3 as `memorybank.settled_after`, which is the `- 1` in "how much story is past the
|
||||
mark"; that subtraction is the whole of it.
|
||||
|
||||
## Subphases
|
||||
|
||||
@@ -356,6 +358,63 @@ on branch B is invisible from branch A, and shared ancestors are visible from bo
|
||||
Memory retrieval reads the *full* lineage (it cannot be windowed) but stays sparse:
|
||||
assert the byte cost on a deep fork.
|
||||
|
||||
**Done, 2026-08-18** (branch `sp3-node-cursors`). **330 tests green**, the 318 the branch
|
||||
started from plus 12 — 11 in `test_branch_clause`'s new sibling `test_memory_nodes.py`
|
||||
and one on migration 56. The baseline contract passes **unmodified**, which was the pass
|
||||
condition. `app/context/cursors.py` is the new module; migrations 53–56 add
|
||||
`memory_cursor_branch_id/_depth` and `summary_cursor_branch_id/_depth` and translate the
|
||||
old counts into them.
|
||||
|
||||
Seven things worth not rediscovering:
|
||||
|
||||
- **An anchor is a coordinate, not a pointer, and that is what deleted the machinery.**
|
||||
Half of this subphase was expected to be rewriting the cursor bookkeeping in depth
|
||||
terms. None of it needed rewriting: `count_after(41)` is well defined with node 41
|
||||
deleted, and deleting node 12 does not change what "past node 41" means. So
|
||||
`note_action_removed`, `_rewind_cursors_to_index`, `position_of_index` and the
|
||||
post-turn clamp did not become depth-shaped versions of themselves — they became
|
||||
nothing. **If a mark still needs correcting when the story changes, it is still a
|
||||
position.**
|
||||
- **The clamp had its own trap and it also goes.** `run_post_turn` clamped both cursors
|
||||
to the story length every pass, deliberately against the *full* count, because
|
||||
clamping to the settled count rewound a caught-up adventure a step and re-covered an
|
||||
action. An anchor past the tip is not a broken value: `settled_after` reports nothing
|
||||
to do, and the story growing back past it resumes exactly where it left off.
|
||||
- **`prune_dangling_memories` became a lookup, and got stricter by accident.** A memory
|
||||
hangs off the node its block ends on, so "what did this node produce?" is
|
||||
`(branch_id, depth)` — `memorybank.forget_node`. The scan it replaces could only ever
|
||||
notice damage *after* the fact (a covered range past `max(index)`), and could not
|
||||
notice at all when the node was deleted from the middle of a story that still had
|
||||
later actions. Withdrawing the memory is half the job: the ground it covered is still
|
||||
behind the mark, so the mark goes back to `source_start - 1` — a depth, whether or not
|
||||
a row still sits there.
|
||||
- **A memory with no node had to be spelled out in the clause.** A hand-written memory
|
||||
summarises nothing, so it carries a branch and a NULL depth. Every ancestor entry in a
|
||||
lineage clause is capped `depth <= fork`, and NULL fails that — so a typed memory would
|
||||
have become invisible at the first fork after it was written, with nothing to see but a
|
||||
prompt that stopped mentioning it. `Path.clause(unanchored=True)` is that case, and
|
||||
actions never pass it: an action with no depth is a pre-tree row no read should see.
|
||||
- **Retrieval reads the whole lineage, and it is free.** Measured on two stories of 84
|
||||
actions and 14 memories each, one flat and one forked twenty times: **1,807 B against
|
||||
1,823 B**. The clause carries 22 branch terms instead of one, and the clause is not what
|
||||
crosses the wire. The egress shapes are otherwise byte-identical to SP1's — index
|
||||
1.8 kB, page load 62.7 kB, turn 733.8 kB.
|
||||
- **Three reads stay adventure-wide, deliberately.** Embedding and eviction are facts
|
||||
about the row and about the bank, not about the path — skipping a sibling's memories
|
||||
would only mean embedding them at the moment somebody switched to them, and evicting the
|
||||
memories of a story nobody is reading is the right thing to evict first. The Memories
|
||||
drawer is management rather than retrieval, and hiding a branch's memories there would
|
||||
make them unfindable in a phase whose rule is that nothing is removed automatically.
|
||||
- **The v1 bundle still speaks positions, in exactly two places.** Export counts the
|
||||
anchor back into a position; import translates the other way, but only after the
|
||||
actions exist, because that is the one moment the two coordinate systems can be lined
|
||||
up. `cursors.position_of` and `cursors.anchor_at_position` are the whole of what still
|
||||
knows about positions, and SP6's v2 format retires them.
|
||||
|
||||
Migrations 53–56 rewrite `adventures`, not `actions` — a few hundred rows against a few
|
||||
hundred thousand — so **this deploy needs no `VACUUM FULL` of its own**. The one SP1 owes
|
||||
is still owed.
|
||||
|
||||
### SP4 — Variants become sibling nodes
|
||||
|
||||
Retry stops mutating a row. It writes a sibling leaf at the same depth.
|
||||
@@ -412,7 +471,9 @@ jsdom has no layout, so scroll position still needs eyes.
|
||||
### SP8 — Drop the legacy columns
|
||||
|
||||
Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`,
|
||||
`variant_count`, followed by `VACUUM FULL actions;`.
|
||||
`variant_count`, followed by `VACUUM FULL actions;`. **Also `adventures.memory_cursor`
|
||||
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.
|
||||
|
||||
**Verify:** full suite; egress ceilings; a measured before/after size, aggregates only.
|
||||
|
||||
|
||||
+58
-20
@@ -3,7 +3,7 @@
|
||||
Read this first when picking the project back up. Updated at the end of a working
|
||||
session; the per-phase plan files hold the detail, this holds the thread.
|
||||
|
||||
**Last updated: 2026-08-17.**
|
||||
**Last updated: 2026-08-18.**
|
||||
|
||||
---
|
||||
|
||||
@@ -78,27 +78,32 @@ needed; nothing requires reading a row of anyone's story.
|
||||
|
||||
## Pick up here
|
||||
|
||||
**`plan/14-phase-story-tree.md`, SP3 — memories and the summary attach to nodes.** SP0
|
||||
(the regression contract and the `--rich` fixture), SP1 (schema, migration, and the writer
|
||||
that keeps new rows on the tree) and SP2 (the branch clause: every action read now selects
|
||||
on `(branch_id, depth)` through `app/context/lineage.py`) are done and green; nothing is
|
||||
deployed yet. SP3 turns `memory_cursor`/`summary_cursor` from positions in a shifting list
|
||||
into node anchors, and deletes the cursor-position machinery that goes with them —
|
||||
`position_of_index`, `note_action_removed`, `_rewind_cursors_to_index`,
|
||||
`prune_dangling_memories`. **`settled_story_actions` and the holdback stay until SP4**:
|
||||
they exist because retry mutates a row in place, and retry stops doing that in SP4, not
|
||||
in SP3. Deleting them early reopens the exact bug they were written for.
|
||||
**`plan/14-phase-story-tree.md`, SP4 — variants become sibling nodes.** SP0 (the
|
||||
regression contract and the `--rich` fixture), SP1 (schema, migration, and the writer that
|
||||
keeps new rows on the tree), SP2 (the branch clause: every action read selects on
|
||||
`(branch_id, depth)` through `app/context/lineage.py`) and SP3 (memories hang off nodes,
|
||||
and both marks are `(branch_id, depth)` through `app/context/cursors.py`) are done and
|
||||
green; **nothing is deployed yet**. SP4 is where retry stops rewriting a row and writes a
|
||||
sibling leaf at the same depth instead, where `state_before`/`world_state_before` become
|
||||
*after* snapshots, and where the legacy `variants` JSON is migrated into rows. It is also
|
||||
the first subphase allowed to move the baseline test, and only for
|
||||
`variant_count`/`variant_index` semantics.
|
||||
|
||||
**The schema is live in code but not on production.** When SP1 ships, the deploy needs
|
||||
one `VACUUM FULL actions;` on the direct (non-`-pooler`) endpoint afterwards — it rewrites
|
||||
every row. See the 144 MB lesson at the top of this file.
|
||||
**The schema is live in code but not on production.** When this ships, the deploy needs
|
||||
one `VACUUM FULL actions;` on the direct (non-`-pooler`) endpoint afterwards — SP1's
|
||||
migration rewrites every row, and SP4's does it again. SP3's own migrations touch
|
||||
`adventures` only and need no vacuum. See the 144 MB lesson at the top of this file.
|
||||
|
||||
Two things to carry into it:
|
||||
Three things to carry into it:
|
||||
|
||||
- **Every action read goes through `context/lineage.py`.** A memory read has to as well —
|
||||
the same `Path` builds a clause over `Memory` — and it reads the *full* lineage, not a
|
||||
window: retrieval is long-range recall and cannot be windowed. It stays affordable
|
||||
because memories are sparse, so assert the byte cost on a deep fork.
|
||||
- **The holdback dies in SP4 and nowhere earlier.** `settled_story_actions` exists
|
||||
because retry mutates a row in place; it survived SP3 as the `- 1` inside
|
||||
`memorybank.settled_after`. Retry stops mutating in SP4, which is the only point at
|
||||
which removing it does not reopen the bug it was written for.
|
||||
- **Anything derived attaches to the node that produced it.** A memory now does
|
||||
(`tree.attach_memory`), and so do both marks. A sibling leaf is a node, so whatever SP4
|
||||
derives per attempt hangs off the attempt — and `memorybank.forget_node` is what
|
||||
withdraws it when the node goes.
|
||||
- **Weigh new columns in bytes.** `actions` is already the table that fills the disk.
|
||||
`tests/test_egress.py` has byte ceilings now — they will tell you.
|
||||
|
||||
@@ -112,6 +117,39 @@ drive it before rewriting it.
|
||||
|
||||
---
|
||||
|
||||
## What happened on 2026-08-18 — the tree, SP3
|
||||
|
||||
The memory bank stopped counting. `memory_cursor` and `summary_cursor` were positions in
|
||||
the story — "the first twelve actions are covered" — and a position moves when an action
|
||||
in front of it is deleted, so it silently starts covering one it has never read. Both are
|
||||
now node anchors, `(branch_id, depth)`, through the new `app/context/cursors.py`; a memory
|
||||
hangs off the node whose block it ends on; and retrieval selects through the branch
|
||||
clause, so a memory made on one branch never reaches a prompt on another. **330 tests
|
||||
green**, and the SP0 baseline still passes unmodified. Branch `sp3-node-cursors`.
|
||||
|
||||
Three things to carry forward:
|
||||
|
||||
- **Most of the work was deleting, and that was the test of the design.** The plan listed
|
||||
four pieces of cursor machinery to remove and the expectation was that each would come
|
||||
back in depth-shaped form. None did. `count_after(41)` is well defined with node 41
|
||||
deleted and unchanged by anything deleted in front of it, so `note_action_removed`,
|
||||
`_rewind_cursors_to_index`, `position_of_index` and the every-pass clamp in
|
||||
`run_post_turn` all became nothing at all. **If a mark still needs correcting when the
|
||||
story changes, it is still a position.** The one thing a delete still does is withdraw
|
||||
what the node *produced* — `memorybank.forget_node`, a lookup on `(branch_id, depth)`
|
||||
where `prune_dangling_memories` was a scan that could only notice damage afterwards.
|
||||
- **A NULL is not a small depth, and it nearly cost a feature.** A hand-written memory
|
||||
summarises no node, so it has a branch and no depth; every ancestor entry in a lineage
|
||||
clause is capped `depth <= fork`, and NULL fails that test. A memory somebody typed
|
||||
would have disappeared at the first fork after they typed it, with no error anywhere —
|
||||
just a prompt that stopped mentioning it. `Path.clause(unanchored=True)` names that case
|
||||
explicitly, and actions are deliberately not given it.
|
||||
- **Reading the whole ancestry for recall is free.** Retrieval cannot be windowed — that
|
||||
is the point of it — so the clause names every branch in the lineage. Two 84-action
|
||||
stories with 14 memories each, one flat and one forked twenty times: **1,807 B against
|
||||
1,823 B**. Twenty-two branch terms cost nothing, because the clause is not what crosses
|
||||
the wire. Index (1.8 kB), page load (62.7 kB) and turn (733.8 kB) are unmoved.
|
||||
|
||||
## What happened on 2026-08-17, part five — the tree, SP2
|
||||
|
||||
Every read of an action now goes through one module. `app/context/lineage.py` turns a
|
||||
@@ -407,7 +445,7 @@ the SQLite dev parity this codebase protects on purpose).
|
||||
|
||||
```
|
||||
cd backend
|
||||
.venv/Scripts/python.exe -m pytest tests/ # 297 tests (~38s)
|
||||
.venv/Scripts/python.exe -m pytest tests/ # 330 tests (~55s)
|
||||
.venv/Scripts/python.exe -m tools.stress_session # egress report (SQLite)
|
||||
|
||||
# Same harness against a real Postgres. The target must be a THROWAWAY database
|
||||
|
||||
Reference in New Issue
Block a user