Files
interactive-story/backend/app/context/lineage.py
T
parththakkar106andClaude Opus 5 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
2026-08-18 19:14:07 +05:30

265 lines
11 KiB
Python

"""Phase 14 — which nodes are "this story".
`tree.py` decides where a node is written. This module is the other half: it
decides which nodes a read can see, and it is the **only** place that knows.
A branch owns the nodes played on it and *borrows* everything before its fork
point from its ancestors, so "the story on branch C" is not a column you can
filter on — it is an OR of ranges::
(branch_id = C) -- C's own nodes, to the tip
OR (branch_id = B AND depth <= 5)
OR (branch_id = A AND depth <= 3)
which is exactly what `branches.lineage` spells out, newest first, computed
once when the fork happens. Reads never walk parent pointers to rebuild it.
Two properties fall out of the shape, and both are load-bearing:
* **The ranges are disjoint and descending.** A branch's own nodes always sit
deeper than its fork point, and each lineage entry is capped at the fork
depth of the branch beneath it. So ordering the whole clause by `depth`
descending is the same as reading entry 0's nodes, then entry 1's, then
entry 2's — which is what lets a tail read use only the newest few entries
and stop.
* **Clause count is bounded by the context window, not by fork count.** A
200-fork story whose newest branch is 40 turns long reads with one clause,
because the window is covered before the second entry is reached. That is
`prefix_covering`, and it is why `history.window_covering` can keep its shape.
Everything here is a read. Nothing in this module creates a branch or writes a
row: an adventure with no branch has no story, and healing that is the write
side's job (`tree.place_action`, and the flush guard in `models.py` behind it).
"""
from sqlalchemy import and_, false, or_
from sqlalchemy.orm import Session
from .. import models
# The depth of an adventure with no actions. Mirrors tree.NO_DEPTH; kept
# separately so a read never has to import the write half.
NO_DEPTH = -1
def entries_of(branch: models.Branch) -> list[tuple[int, int | None]]:
"""`branch.lineage` as (branch_id, max_depth) pairs, newest first.
An empty lineage reads as "this branch alone, to its tip" rather than as an
error. That is what a root branch's lineage means, and it is what a branch
row looks like in the moment between being inserted and having its own id
to name — so the fallback is the truth, not a guess.
"""
raw = branch.lineage if isinstance(branch.lineage, list) else []
entries: list[tuple[int, int | None]] = []
for item in raw:
# JSON round-trips lists; a hand-written row might hold tuples.
if not isinstance(item, (list, tuple)) or not item:
continue
branch_id = item[0]
max_depth = item[1] if len(item) > 1 else None
if not isinstance(branch_id, int):
continue
entries.append((branch_id, max_depth if isinstance(max_depth, int) else None))
return entries or [(branch.id, None)]
class Path:
"""One story, as a clause and as a predicate.
Holds the lineage entries newest first, plus the depth of the tip, which is
only used to estimate how much story each entry covers.
"""
def __init__(self, entries: list[tuple[int, int | None]], tip: int | None = None):
self.entries = entries
self.tip = tip
def __bool__(self) -> bool:
return bool(self.entries)
def __len__(self) -> int:
return len(self.entries)
# ---------------------------------------------------------------- SQL
def clause(
self,
model=models.Action,
count: int | None = None,
unanchored: bool = False,
):
"""The branch clause, over `model` (`Action` or `Memory`).
`count` limits it to the newest `count` lineage entries — the windowed
read. `None` is the whole lineage, which is what anything counting from
the *oldest* end (a slice, a total) has to use.
`unanchored` keeps rows with no depth. Only memories ever have one: a
hand-written memory summarises no node, so it has a branch but no
depth, and a capped `depth <= n` would drop it the moment its branch
stopped being the newest entry — a memory vanishing at the first fork
after it was typed. An action with no depth is a pre-tree row that no
read should see, so actions never pass this.
Actions also have to be *live* (SP4). A coordinate can hold several
attempts at the same turn, and the story tells one of them; the losing
siblings sit at the same branch and depth and are excluded here, once,
so that no read of the story has to know that retries exist. Only
`app/attempts.py` looks past this.
An empty path yields `false`, not "no filter": an adventure whose nodes
carry no branch has no story, and the loud version of that is an empty
page, not every branch at once.
"""
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, unanchored) for b, d in entries])
if model is models.Action:
return and_(on_path, models.Action.live.is_(True))
return on_path
@staticmethod
def _entry_clause(model, branch_id: int, max_depth: int | None, unanchored=False):
if max_depth is None:
return model.branch_id == branch_id
within = model.depth <= max_depth
if unanchored:
within = or_(within, model.depth.is_(None))
return and_(model.branch_id == branch_id, within)
# ------------------------------------------------------------- Python
def contains(self, node) -> bool:
"""The Python half of `clause()` — keep the two in step.
Used where the rows are already in memory (the scripting pipeline hands
user scripts the whole history), so an already-loaded collection can be
cut down to the path without a second read.
`live` is checked first, and only on rows that have the attribute:
memories have no siblings to lose to.
"""
if getattr(node, "live", True) is False:
return False
for branch_id, max_depth in self.entries:
if node.branch_id != branch_id:
continue
if max_depth is None:
return True
if node.depth is not None and node.depth <= max_depth:
return True
return False
def sort_key(self, node) -> tuple[int, int]:
"""Oldest-first ordering. `depth` is the ordering key; `id` breaks the
tie a pre-tree row (depth NULL) or a future sibling pair would leave."""
return (node.depth if node.depth is not None else NO_DEPTH, node.id or 0)
# ------------------------------------------------------------ windowing
def prefix_covering(self, rows: int) -> int:
"""How many lineage entries it takes to hold the newest `rows` nodes.
An estimate from depth arithmetic, not a query: entry *i* covers the
depths between its own cap and the cap of the entry below it, and there
is at most one node per depth on a path. So the count it returns is
never too many, and is too few only where the story has gaps — an
action deleted from the middle. The caller widens to the full lineage
if the window comes up short, which costs a second query on a story
somebody has deleted from, and nothing at all otherwise.
"""
total = len(self.entries)
if rows <= 0 or total == 0:
return total
covered = 0
for i, (_, max_depth) in enumerate(self.entries):
top = self.tip if max_depth is None else max_depth
below = self.entries[i + 1][1] if i + 1 < total else NO_DEPTH
if top is None or below is None:
# No tip recorded, or a cap missing from a hand-written row:
# nothing to estimate from, so read the lot rather than guess
# short and hide the older half of the story.
return total
covered += max(top - below, 0)
if covered >= rows:
return i + 1
return total
def covering_after(self, depth: int) -> int:
"""How many lineage entries can hold a node deeper than `depth`.
The counterpart to `prefix_covering`, and unlike it this is exact
rather than an estimate: entry *i* holds nothing deeper than its own
cap, and the caps descend, so the first entry capped at or below
`depth` ends the search — it and everything older is behind the
boundary. Reading "the story after the cursor" therefore names one
branch on any story whose cursor is on its newest branch, however
often it has forked.
"""
for i, (_, max_depth) in enumerate(self.entries):
if max_depth is not None and max_depth <= depth:
return i
return len(self.entries)
def depth_on(self, branch_id: int | None, depth: int) -> int:
"""A stored `(branch_id, depth)` anchor, read as a depth on *this* path.
An anchor is how far along a story some derived work has got — which
memories cover, what the summary has folded in. It names a node, so
moving to another path has to be answered rather than assumed:
* the anchor's branch is on this path — the depth stands, capped at the
fork the path takes off that branch, because nothing past the fork is
on this story;
* the branch is not on this path at all — the work was done on ground
this story never travelled, so nothing here is covered.
The second case cannot arise while an adventure has one branch: the
anchor is always set from a node on it. It exists because the fallback
for "I don't know" must be to redo the work, not to skip it.
"""
if depth <= NO_DEPTH:
return NO_DEPTH
if branch_id is None:
# A pre-tree anchor, or one set by hand. There is one story, so the
# depth is a position in it and means what it says.
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)
return NO_DEPTH
def branch_of(db: Session, adventure: models.Adventure) -> models.Branch | None:
"""The branch this adventure is being read at, or None if it has none.
Deliberately not `tree.head_branch`, which creates one: a GET must not
write. An adventure with no branch row also has no nodes carrying a branch,
so the two agree — both say "no story here".
"""
if adventure.head_branch_id is not None:
branch = db.get(models.Branch, adventure.head_branch_id)
if branch is not None:
return branch
# A head naming a branch that is gone: fall through to the root, the
# same recovery `tree.head_branch` makes on the write side.
return (
db.query(models.Branch)
.filter(
models.Branch.adventure_id == adventure.id,
models.Branch.parent_branch_id.is_(None),
)
.order_by(models.Branch.id)
.first()
)
def path_of(db: Session, adventure: models.Adventure) -> Path:
"""The story the adventure's head is currently on."""
branch = branch_of(db, adventure)
if branch is None:
return Path([], adventure.head_depth)
return Path(entries_of(branch), adventure.head_depth)