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
265 lines
11 KiB
Python
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)
|