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
192 lines
8.0 KiB
Python
192 lines
8.0 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):
|
|
"""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.
|
|
|
|
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()
|
|
return or_(*[self._entry_clause(model, b, d) for b, d in entries])
|
|
|
|
@staticmethod
|
|
def _entry_clause(model, branch_id: int, max_depth: int | None):
|
|
if max_depth is None:
|
|
return model.branch_id == branch_id
|
|
return and_(model.branch_id == branch_id, model.depth <= max_depth)
|
|
|
|
# ------------------------------------------------------------- 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.
|
|
"""
|
|
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 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)
|