M3: move the story's head instead of deleting its turns
Undo deleted. It removed the trailing AI action and the player action in front of it, pruned the memories covering them, and let the tip fall back to whatever survived. That made it the one operation in the application that destroyed accepted story, and it was why there was no Redo: the turns to move forward into no longer existed. Phase 0B demonstrated the head-cursor alternative in a disposable spike; this is that concept as production code. backend/app/head.py is the whole of it. Three questions that used to be one — where the story is being read, how far it is retained, and where it opens — are now three functions, and every caller that moves the head or asks about it goes through this module. The spike put the fork check in the write path and left Retry and Add-take on the old one; sharing the rules is what stops that divergence coming back. lineage.Path now caps every entry at the head, so hiding the retained future costs nothing at the call sites: the transcript, the context builder, attempts.preceding and memory retrieval already funnelled through path_of and narrow together. Path.uncapped() is the deliberate exception, and only Redo and the fork check may use it. The memory bank needs no pruning for the same reason — a memory carries the coordinate of the node its block ends on, so one derived past the head falls outside the capped clause and becomes retrievable again on Redo without having been deleted and re-embedded. Undo alone does not fork. Moving the head is not a decision to abandon anything, since the user may be reading or about to Redo; the first write below the head is where the story states which continuation it means. A head already at the tip forks nothing, so a story that is never undone forks exactly as often as it did before and the branch table does not fill up with one branch per turn. Redo follows the lineage rather than choosing among branches, which is what invalidates it after a divergence with no flag to set or clear. Migrations 78 and 79 give a branch superseded_at and superseded_depth. Nothing reads them to decide behaviour — Redo is decided by the lineage, so a stale or hand-edited value here cannot make the story wrong. They exist so the cleanup and discarded-history features left to a later version have something to select on, and so a divergence is observable in a test. Deleting an action no longer drags a moved-back head forward to the recomputed tip, which would have silently redone the story. can_undo and can_redo ride on AdventureOut and ActionPage because the client can work out neither for itself: the campaign opening may be off the top of the loaded window, and the retained future is never sent to it. This is a checkpoint, not the finished milestone. 601 backend tests pass. Five still assert the destructive contract — they count rows after an undo and expect the story to be shorter — and need rewriting against the new one; the world-state assertions inside them already pass. Export and import do not yet carry the head coordinate, so a bundle still reopens at the deepest node and can silently redo an undone story, which is the Phase 0B finding this milestone exists to close. The browser has no Redo control yet. None of the M3 acceptance coverage (D01-D10, E01-E04, I01-I03, I07, L01-L02) is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fdd2547f0
commit
903fa7a74f
@@ -78,7 +78,16 @@ class Path:
|
||||
"""One story, expressed as a SQL clause and as a Python predicate.
|
||||
|
||||
The object holds the lineage entries newest first, plus the depth of the
|
||||
tip. The tip is used only to estimate how much story each entry covers.
|
||||
head. Every entry is read as capped at the head, which is what makes the
|
||||
active head a position the whole application honours (M3).
|
||||
|
||||
Before M3 the head was always the deepest node, so the cap never bit and the
|
||||
tip was used only to estimate how much story each entry covers. Undo now
|
||||
moves the head backward without deleting anything, so a path can have live
|
||||
nodes past its head, and those nodes are not part of the story being told.
|
||||
Capping here is what hides them, and it hides them from every read at once:
|
||||
the transcript, the context builder, `attempts.preceding`, and memory
|
||||
retrieval all funnel through `path_of`.
|
||||
"""
|
||||
|
||||
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
|
||||
@@ -91,6 +100,40 @@ class Path:
|
||||
def __len__(self) -> int:
|
||||
return len(self.entries)
|
||||
|
||||
# ------------------------------------------------------------- the head
|
||||
|
||||
def _cap(self, max_depth: int | None) -> int | None:
|
||||
"""Returns `max_depth` limited by the head, which no read may pass.
|
||||
|
||||
Three cases, and the third is the one M3 added:
|
||||
|
||||
* No head recorded (`tip is None`). The caller asked for the lineage
|
||||
without a position, so the entry's own cap stands. `tree` builds such
|
||||
a path when it resolves the node in front of a depth.
|
||||
* An uncapped entry, which means "this branch through to its tip". The
|
||||
head is the cap.
|
||||
* A capped entry, which is an ancestor capped at the fork depth. The
|
||||
head still wins when it sits behind that fork, because undoing below
|
||||
a fork point is undoing into the shared prefix. Taking the smaller of
|
||||
the two is what lets Undo walk back past a fork instead of stopping
|
||||
there — safe now that it deletes nothing.
|
||||
"""
|
||||
if self.tip is None:
|
||||
return max_depth
|
||||
if max_depth is None:
|
||||
return self.tip
|
||||
return min(max_depth, self.tip)
|
||||
|
||||
def uncapped(self) -> "Path":
|
||||
"""Returns the same lineage read through to its retained tip.
|
||||
|
||||
This is the retained history, head or no head: what Redo can still walk
|
||||
forward into, and what a write below the head has to fork away from.
|
||||
Only those two callers should use it. Every read of *the story* wants
|
||||
the capped path.
|
||||
"""
|
||||
return Path(self.entries, None)
|
||||
|
||||
# ---------------------------------------------------------------- SQL
|
||||
|
||||
def clause(
|
||||
@@ -125,7 +168,9 @@ class Path:
|
||||
entries = self.entries if count is None else self.entries[:count]
|
||||
if not entries:
|
||||
return false()
|
||||
on_path = or_(*[self._entry_clause(model, b, d) for b, d in entries])
|
||||
on_path = or_(
|
||||
*[self._entry_clause(model, b, self._cap(d)) for b, d in entries]
|
||||
)
|
||||
if model is models.Action:
|
||||
return and_(on_path, models.Action.live.is_(True))
|
||||
return on_path
|
||||
@@ -155,9 +200,10 @@ class Path:
|
||||
for branch_id, max_depth in self.entries:
|
||||
if node.branch_id != branch_id:
|
||||
continue
|
||||
if max_depth is None:
|
||||
cap = self._cap(max_depth)
|
||||
if cap is None:
|
||||
return True
|
||||
if node.depth is not None and node.depth <= max_depth:
|
||||
if node.depth is not None and node.depth <= cap:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -187,7 +233,7 @@ class Path:
|
||||
return total
|
||||
covered = 0
|
||||
for i, (_, max_depth) in enumerate(self.entries):
|
||||
top = self.tip if max_depth is None else max_depth
|
||||
top = self._cap(max_depth)
|
||||
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
|
||||
if top is None or below is None:
|
||||
# Either no tip was recorded, or a hand-written row is missing a
|
||||
@@ -211,7 +257,8 @@ class Path:
|
||||
story has forked.
|
||||
"""
|
||||
for i, (_, max_depth) in enumerate(self.entries):
|
||||
if max_depth is not None and max_depth <= depth:
|
||||
cap = self._cap(max_depth)
|
||||
if cap is not None and cap <= depth:
|
||||
return i
|
||||
return len(self.entries)
|
||||
|
||||
@@ -241,7 +288,8 @@ class Path:
|
||||
return depth
|
||||
for entry_branch, max_depth in self.entries:
|
||||
if entry_branch == branch_id:
|
||||
return depth if max_depth is None else min(depth, max_depth)
|
||||
cap = self._cap(max_depth)
|
||||
return depth if cap is None else min(depth, cap)
|
||||
return NO_DEPTH
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user