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
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""Phase 14 — putting nodes on the story tree.
|
|
|
|
The write half of the tree. Which branch a new node hangs off, what depth it
|
|
gets, and where an adventure's head points all live here, because every one of
|
|
them is the kind of thing that is silently wrong when it is spread across four
|
|
call sites: a node written without a branch is a node no read can see, and it
|
|
fails by disappearing rather than by raising.
|
|
|
|
The read half — the lineage clause that turns a branch into "this story" —
|
|
lives beside it in `context/lineage.py`.
|
|
|
|
Until forking ships there is exactly one branch per adventure and `depth` is
|
|
the number `index` already held, so everything in this module is bookkeeping
|
|
that changes nothing observable. That is the point: by the time a read depends
|
|
on these columns, every row has them — including the rows written between the
|
|
two deploys, which no migration will ever visit.
|
|
|
|
SP2 added `place_new_nodes`, which the session calls on every flush. Wiring the
|
|
call sites was enough while nothing read the columns; now that reads select on
|
|
them, "every writer remembers" is a promise that has to hold for every fixture,
|
|
script and test ever written too, and its breach is a story quietly missing
|
|
turns. So the invariant is enforced at the flush instead of asked for.
|
|
"""
|
|
|
|
from sqlalchemy import func, insert, update
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import models
|
|
|
|
# The head depth of an adventure with no actions. Keeps "the next node goes at
|
|
# head_depth + 1" true with no special case, and mirrors migrations.NO_DEPTH.
|
|
NO_DEPTH = -1
|
|
|
|
|
|
def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
|
"""The adventure's root branch, created on first use.
|
|
|
|
Get-or-create rather than created-with-the-adventure, because the adventures
|
|
that need one most are the ones that already exist: a bundle being imported,
|
|
a fixture built straight through the ORM, or a database whose migration ran
|
|
before this code shipped.
|
|
"""
|
|
branch = (
|
|
db.query(models.Branch)
|
|
.filter(
|
|
models.Branch.adventure_id == adventure.id,
|
|
models.Branch.parent_branch_id.is_(None),
|
|
)
|
|
.order_by(models.Branch.id)
|
|
.first()
|
|
)
|
|
if branch is not None:
|
|
return branch
|
|
# Inserted through Core rather than through the unit of work, because this
|
|
# also runs from `place_new_nodes` inside a flush, and a nested ORM flush
|
|
# inside a flush raises. Same transaction either way, so it rolls back with
|
|
# everything else. The lineage names the branch's own id, so it takes a
|
|
# second statement — once per adventure, ever.
|
|
new_id = db.execute(
|
|
insert(models.Branch).values(
|
|
adventure_id=adventure.id,
|
|
parent_branch_id=None,
|
|
fork_depth=None,
|
|
lineage=[],
|
|
created_at=models.utcnow(),
|
|
)
|
|
).inserted_primary_key[0]
|
|
db.execute(
|
|
update(models.Branch)
|
|
.where(models.Branch.id == new_id)
|
|
.values(lineage=[[new_id, None]])
|
|
)
|
|
return db.get(models.Branch, new_id)
|
|
|
|
|
|
def head_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
|
"""The branch new nodes are played onto."""
|
|
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 is a bug somewhere else. Recover
|
|
# onto the root instead of refusing to play — the alternative is an
|
|
# adventure nobody can add to.
|
|
branch = root_branch(db, adventure)
|
|
adventure.head_branch_id = branch.id
|
|
return branch
|
|
|
|
|
|
def place_action(
|
|
db: Session, adventure: models.Adventure, action: models.Action
|
|
) -> models.Branch:
|
|
"""Put `action` on the head branch and move the head to it.
|
|
|
|
`depth` follows `index` while the two coexist. They have to agree: a read
|
|
ordering by depth and a cursor counting in index space are describing the
|
|
same story, and SP2 swaps one for the other under everything at once.
|
|
"""
|
|
branch = head_branch(db, adventure)
|
|
action.branch_id = branch.id
|
|
if action.depth is None:
|
|
action.depth = action.index
|
|
adventure.head_branch_id = branch.id
|
|
if action.depth is not None and action.depth > adventure.head_depth:
|
|
adventure.head_depth = action.depth
|
|
return branch
|
|
|
|
|
|
def place_memory(
|
|
db: Session, adventure: models.Adventure, memory: models.Memory
|
|
) -> models.Branch:
|
|
"""Attach a memory to the node that produced it.
|
|
|
|
`source_end` is the index of the last action the memory summarises, which is
|
|
that node's depth. A hand-written memory summarises nothing, so its depth
|
|
stays NULL and it belongs to the adventure rather than to a path.
|
|
"""
|
|
branch = head_branch(db, adventure)
|
|
memory.branch_id = branch.id
|
|
if memory.depth is None and memory.source_end is not None:
|
|
memory.depth = memory.source_end
|
|
return branch
|
|
|
|
|
|
def place_new_nodes(session: Session) -> None:
|
|
"""Place every unplaced node about to be inserted. Runs on every flush.
|
|
|
|
The call sites still call `place_action` / `place_memory` themselves, and
|
|
should: a node placed at the call site is placed *before* the code around
|
|
it reads the row back, and the explicit call is what makes the ordering
|
|
visible. This is the floor under them — a fixture built straight through
|
|
the ORM, a script, a test, or a call site added next year gets a branch
|
|
without knowing the tree exists.
|
|
|
|
Nodes whose adventure has not been inserted yet are left alone: there is no
|
|
id to hang a branch off, and an Action needs `adventure_id` to be written
|
|
at all, so the case does not arise from any writer we have.
|
|
"""
|
|
for obj in list(session.new):
|
|
if isinstance(obj, models.Action):
|
|
place = place_action
|
|
elif isinstance(obj, models.Memory):
|
|
place = place_memory
|
|
else:
|
|
continue
|
|
if obj.branch_id is not None or obj.adventure_id is None:
|
|
continue
|
|
adventure = session.get(models.Adventure, obj.adventure_id)
|
|
if adventure is not None:
|
|
place(session, adventure, obj)
|
|
|
|
|
|
def refresh_head(db: Session, adventure: models.Adventure) -> None:
|
|
"""Re-derive the head depth after nodes were removed (undo, delete).
|
|
|
|
A branch with nothing on it sits at its fork point, because that is the last
|
|
node its story contains — borrowed from the parent, but the tip all the
|
|
same. A root branch with nothing on it has no story at all.
|
|
"""
|
|
branch = head_branch(db, adventure)
|
|
tip = (
|
|
db.query(func.max(models.Action.depth))
|
|
.filter(models.Action.branch_id == branch.id)
|
|
.scalar()
|
|
)
|
|
if tip is None:
|
|
tip = branch.fork_depth if branch.fork_depth is not None else NO_DEPTH
|
|
adventure.head_depth = tip
|