Read one story, and know which one
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
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
d3756abdaa
commit
563b9af9cf
+118
-24
@@ -12,10 +12,10 @@ This module serves those shapes directly from SQL — a tail, a slice, a count
|
||||
so the read is bounded by the context budget instead of by the length of the
|
||||
story.
|
||||
|
||||
Two rules hold everything together:
|
||||
Three rules hold everything together:
|
||||
|
||||
* **One definition of "story action".** The cursors in memorybank are
|
||||
*positions* in this filtered, index-ordered list, so SQL and Python must
|
||||
*positions* in this filtered, depth-ordered list, so SQL and Python must
|
||||
agree on membership exactly or a cursor silently points at a different
|
||||
action. `_STORY_TEXT` and `is_story_text()` are that one definition, written
|
||||
twice; keep them in step.
|
||||
@@ -23,12 +23,22 @@ Two rules hold everything together:
|
||||
scripting pipeline hands the whole history to user scripts, as AI Dungeon
|
||||
does), every helper here slices that instead of issuing a query, so a
|
||||
scripted adventure pays what it always paid and nothing more.
|
||||
* **Every read goes through the branch clause** (Phase 14). `adventure.actions`
|
||||
is every branch's actions, not the story being played — so the shortcut above
|
||||
cuts the loaded collection down to the path before slicing it, exactly as the
|
||||
SQL does. This is the line that would silently assemble a prompt out of two
|
||||
different stories, which is why `lineage.Path` owns both halves of it.
|
||||
|
||||
Ordering is by `depth` now, not `index`. The two hold the same numbers until
|
||||
retry stops mutating rows (SP4), but only one of them is a position along a
|
||||
path.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, inspect as sa_inspect
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from .. import models
|
||||
from . import lineage
|
||||
|
||||
# How many of the newest actions to read before checking whether the token
|
||||
# budget is covered. When it isn't, the next size is worked out from the
|
||||
@@ -38,6 +48,12 @@ WINDOW_START = 32
|
||||
WINDOW_MARGIN = 0.15 # aim this far past the budget, so one more round is rare
|
||||
WINDOW_STEP = 8 # ...and at least this many more actions each round
|
||||
|
||||
# Depth is the ordering key; id breaks the tie that a pre-tree row (depth NULL,
|
||||
# and so invisible anyway) or a future sibling pair would otherwise leave to
|
||||
# the database's mood.
|
||||
_OLDEST_FIRST = (models.Action.depth, models.Action.id)
|
||||
_NEWEST_FIRST = (models.Action.depth.desc(), models.Action.id.desc())
|
||||
|
||||
|
||||
def _sql_stripped(column):
|
||||
"""`column` with leading/trailing whitespace removed, portably.
|
||||
@@ -78,33 +94,75 @@ def _loaded_actions(adventure: models.Adventure) -> list[models.Action] | None:
|
||||
def _from_memory(
|
||||
adventure: models.Adventure, exclude_action_id: int | None
|
||||
) -> list[models.Action] | None:
|
||||
"""The story, from the already-loaded collection, or None to go to SQL.
|
||||
|
||||
The collection is the *adventure's* actions — every branch of it. Cutting
|
||||
it down to the path here is the same filter the SQL applies, and skipping
|
||||
it would hand the context builder a prompt assembled from siblings of the
|
||||
story being played. The path needs a session to read the branch row from;
|
||||
without one there is no answer to give, so say so rather than guess.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is None:
|
||||
return None
|
||||
return [
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return None
|
||||
path = lineage.path_of(db, adventure)
|
||||
rows = [
|
||||
a for a in loaded
|
||||
if is_story_text(a.text) and (exclude_action_id is None or a.id != exclude_action_id)
|
||||
if path.contains(a)
|
||||
and is_story_text(a.text)
|
||||
and (exclude_action_id is None or a.id != exclude_action_id)
|
||||
]
|
||||
# `Adventure.actions` is ordered by `index`; a path is ordered by depth.
|
||||
rows.sort(key=path.sort_key)
|
||||
return rows
|
||||
|
||||
|
||||
def _filters(adventure: models.Adventure, exclude_action_id: int | None) -> list:
|
||||
conditions = [models.Action.adventure_id == adventure.id, _STORY_TEXT]
|
||||
def _filters(
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
) -> list:
|
||||
# adventure_id is redundant beside the branch clause — branch ids are
|
||||
# unique, so a branch already names one adventure. It stays because it is
|
||||
# the cheap half of the check that catches a node written onto the wrong
|
||||
# adventure's branch, and because a clause nobody can read is a clause
|
||||
# nobody maintains.
|
||||
conditions = [
|
||||
models.Action.adventure_id == adventure.id,
|
||||
path.clause(models.Action, count=entries),
|
||||
_STORY_TEXT,
|
||||
]
|
||||
if exclude_action_id is not None:
|
||||
conditions.append(models.Action.id != exclude_action_id)
|
||||
return conditions
|
||||
|
||||
|
||||
def _query(db: Session, adventure: models.Adventure, exclude_action_id: int | None):
|
||||
def _query(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
entries: int | None = None,
|
||||
):
|
||||
# Reasoning traces are never read from replayed history and can be larger
|
||||
# than the narration itself on a reasoning model.
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(*_filters(adventure, exclude_action_id))
|
||||
.filter(*_filters(adventure, path, exclude_action_id, entries))
|
||||
.options(defer(models.Action.reasoning))
|
||||
)
|
||||
|
||||
|
||||
def _count_query(db: Session, adventure: models.Adventure, exclude_action_id: int | None):
|
||||
def _count_query(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
path: lineage.Path,
|
||||
exclude_action_id: int | None,
|
||||
):
|
||||
"""A real `SELECT count(...)`.
|
||||
|
||||
Deliberately not `_query(...).count()`: that wraps the entity select in a
|
||||
@@ -114,7 +172,7 @@ def _count_query(db: Session, adventure: models.Adventure, exclude_action_id: in
|
||||
that greps the SQL cannot tell the two apart.
|
||||
"""
|
||||
return db.query(func.count(models.Action.id)).filter(
|
||||
*_filters(adventure, exclude_action_id)
|
||||
*_filters(adventure, path, exclude_action_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +180,10 @@ def _session(adventure: models.Adventure) -> Session | None:
|
||||
return object_session(adventure)
|
||||
|
||||
|
||||
def _path(db: Session, adventure: models.Adventure) -> lineage.Path:
|
||||
return lineage.path_of(db, adventure)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the API
|
||||
|
||||
def story_actions(
|
||||
@@ -143,7 +205,11 @@ def story_actions(
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
return _query(db, adventure, exclude_action_id).order_by(models.Action.index).all()
|
||||
return (
|
||||
_query(db, adventure, _path(db, adventure), exclude_action_id)
|
||||
.order_by(*_OLDEST_FIRST)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def count(adventure: models.Adventure, exclude_action_id: int | None = None) -> int:
|
||||
@@ -154,7 +220,10 @@ def count(adventure: models.Adventure, exclude_action_id: int | None = None) ->
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return 0
|
||||
return _count_query(db, adventure, exclude_action_id).scalar() or 0
|
||||
return (
|
||||
_count_query(db, adventure, _path(db, adventure), exclude_action_id).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def tail_range(
|
||||
@@ -168,6 +237,15 @@ def tail_range(
|
||||
`skip=0` is the newest slice; `skip=32, limit=16` is the 16 actions just
|
||||
older than the newest 32. Lets a growing window fetch only the part it
|
||||
doesn't already have.
|
||||
|
||||
This is the read the lineage window exists for. The path's ranges are
|
||||
disjoint and descending, so the newest N nodes come from the newest few
|
||||
lineage entries and the rest of the ancestry need not be named at all: a
|
||||
story forked two hundred times reads its tail with as few clauses as one
|
||||
forked never. `prefix_covering` estimates how many entries that takes from
|
||||
depth arithmetic alone; the estimate is only ever short where a middle
|
||||
action was deleted, and then the read widens to the whole lineage and pays
|
||||
one more query.
|
||||
"""
|
||||
if limit <= 0 or skip < 0:
|
||||
return []
|
||||
@@ -178,13 +256,19 @@ def tail_range(
|
||||
db = _session(adventure)
|
||||
if db is None:
|
||||
return []
|
||||
rows = (
|
||||
_query(db, adventure, exclude_action_id)
|
||||
.order_by(models.Action.index.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
path = _path(db, adventure)
|
||||
entries = path.prefix_covering(skip + limit)
|
||||
while True:
|
||||
rows = (
|
||||
_query(db, adventure, path, exclude_action_id, entries)
|
||||
.order_by(*_NEWEST_FIRST)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
if len(rows) >= limit or entries >= len(path):
|
||||
break
|
||||
entries = len(path) # short: widen once, to everything, and re-ask
|
||||
rows.reverse()
|
||||
return rows
|
||||
|
||||
@@ -204,8 +288,11 @@ def slice_(
|
||||
) -> list[models.Action]:
|
||||
"""Story actions at positions [start, start + length), oldest first.
|
||||
|
||||
Positions are into the same filtered, index-ordered list the memory cursors
|
||||
Positions are into the same filtered, depth-ordered list the memory cursors
|
||||
count in, which is why the filter has to match Python's exactly.
|
||||
|
||||
Counts from the oldest end, so it names the whole lineage: there is no
|
||||
prefix of the ancestry that holds "the story's first ten actions".
|
||||
"""
|
||||
if length <= 0 or start < 0:
|
||||
return []
|
||||
@@ -216,8 +303,8 @@ def slice_(
|
||||
if db is None:
|
||||
return []
|
||||
return (
|
||||
_query(db, adventure, exclude_action_id)
|
||||
.order_by(models.Action.index)
|
||||
_query(db, adventure, _path(db, adventure), exclude_action_id)
|
||||
.order_by(*_OLDEST_FIRST)
|
||||
.offset(start)
|
||||
.limit(length)
|
||||
.all()
|
||||
@@ -241,7 +328,7 @@ def position_of_index(adventure: models.Adventure, index: int) -> int:
|
||||
if db is None:
|
||||
return 0
|
||||
return (
|
||||
_count_query(db, adventure, None)
|
||||
_count_query(db, adventure, _path(db, adventure), None)
|
||||
.filter(models.Action.index < index)
|
||||
.scalar()
|
||||
or 0
|
||||
@@ -249,7 +336,14 @@ def position_of_index(adventure: models.Adventure, index: int) -> int:
|
||||
|
||||
|
||||
def max_action_index(adventure: models.Adventure) -> int:
|
||||
"""Highest `Action.index` in the adventure, story text or not. -1 if empty."""
|
||||
"""Highest `Action.index` in the adventure, story text or not. -1 if empty.
|
||||
|
||||
The one read here that is deliberately *not* path-scoped. `index` is the
|
||||
legacy column, kept unread until SP8 drops it, and its only remaining job
|
||||
is to hand the next row a number nothing else holds — which is a fact about
|
||||
the adventure, not about the story being played. Scoping it to a branch
|
||||
would let two branches issue the same index.
|
||||
"""
|
||||
loaded = _loaded_actions(adventure)
|
||||
if loaded is not None:
|
||||
return max((a.index for a in loaded), default=-1)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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)
|
||||
@@ -207,6 +207,12 @@ def check_row_cap(
|
||||
"delete some to make room",
|
||||
)
|
||||
elif kind == "actions":
|
||||
# Every action of the adventure — the whole tree, not the path being
|
||||
# played. That is the number that costs storage, and nothing is ever
|
||||
# auto-pruned, so it is the right one to cap on. It does mean a heavily
|
||||
# branched adventure reaches the cap while its *story* is shorter than
|
||||
# the cap, which is why the message counts "actions in this adventure"
|
||||
# rather than turns.
|
||||
count = _count(db, models.Action, models.Action.adventure_id == adventure.id)
|
||||
cap, subject, hint = (
|
||||
MAX_ACTIONS_PER_ADVENTURE, "actions in this adventure",
|
||||
|
||||
+22
-2
@@ -2,9 +2,9 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
|
||||
String, Table, Text,
|
||||
String, Table, Text, event,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
|
||||
|
||||
from .compression import CompressedJSON
|
||||
from .database import Base
|
||||
@@ -481,3 +481,23 @@ class Settings(Base):
|
||||
from . import security # local import: models is imported before security
|
||||
|
||||
return security.decrypt_secret(self.api_key)
|
||||
|
||||
|
||||
# Phase 14 — the floor under `tree.place_action`.
|
||||
#
|
||||
# From SP2 a read selects on (branch_id, depth): a node written without them is
|
||||
# a node no page, no context build and no memory pass can see, and it fails by
|
||||
# disappearing rather than by raising. The writers all place their nodes
|
||||
# explicitly, but "all the writers remember" is a promise that has to hold for
|
||||
# every fixture, script and test written from here on, so the session enforces
|
||||
# it on the way to the database instead.
|
||||
#
|
||||
# Registered here rather than in tree.py so that importing the models is enough
|
||||
# to arm it — the invariant belongs to the rows, not to the module that usually
|
||||
# writes them. The import is deferred into the callback because tree.py imports
|
||||
# this module.
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _place_new_nodes_on_the_tree(session, flush_context, instances):
|
||||
from . import tree
|
||||
|
||||
tree.place_new_nodes(session)
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm.attributes import set_committed_value
|
||||
from .. import auth, images, limits, memorybank, models, schemas, tree, worldstate
|
||||
from ..context import build_context
|
||||
from ..context import history as context_history
|
||||
from ..context import lineage
|
||||
from ..database import get_db
|
||||
from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
||||
from ..scripting import ScriptPipeline
|
||||
@@ -56,7 +57,7 @@ ACTION_PAGE = 60
|
||||
|
||||
def action_window(
|
||||
db: Session,
|
||||
adventure_id: int,
|
||||
adventure: models.Adventure,
|
||||
before_id: int | None = None,
|
||||
limit: int = ACTION_PAGE,
|
||||
) -> tuple[list[models.Action], int, bool]:
|
||||
@@ -64,50 +65,56 @@ def action_window(
|
||||
|
||||
Returns (actions, total, has_more). `before_id=None` is the newest window.
|
||||
|
||||
Anchored on an action, not on a count, and never on arithmetic over
|
||||
`Action.index`. Two separate reasons, and both bite:
|
||||
Scoped to the story being played — the head branch's lineage — rather than
|
||||
to the adventure, so a sibling branch's turns can never appear in the
|
||||
transcript. `total` counts the same path, because it is what tells the
|
||||
reader there is more above.
|
||||
|
||||
Anchored on an action, not on a count, and never on arithmetic over depth.
|
||||
Two separate reasons, and both bite:
|
||||
|
||||
* **Appends.** Counting back from the newest means every older position
|
||||
shifts when a turn lands. A reader who scrolls up while a turn is
|
||||
generating would be handed a window one row out — re-sending one action
|
||||
and silently skipping another. An anchor is fixed: "older than this one"
|
||||
means the same thing before and after the story grows.
|
||||
* **The story tree.** Index is a dense 0..n sequence today and branching
|
||||
ends that. Comparing indices to order a branch survives; treating them as
|
||||
positions does not.
|
||||
* **The story tree.** Depth is dense today and branching ends that.
|
||||
Comparing depths to order a path survives; treating them as positions
|
||||
does not.
|
||||
|
||||
`has_more` comes from asking for one row past the window rather than from
|
||||
counting, so it costs a row and not a scan.
|
||||
"""
|
||||
total = (
|
||||
db.query(func.count(models.Action.id))
|
||||
.filter(models.Action.adventure_id == adventure_id)
|
||||
.scalar()
|
||||
path = lineage.path_of(db, adventure)
|
||||
on_path = (
|
||||
models.Action.adventure_id == adventure.id,
|
||||
path.clause(models.Action),
|
||||
)
|
||||
total = db.query(func.count(models.Action.id)).filter(*on_path).scalar()
|
||||
if limit <= 0:
|
||||
return [], total, total > 0
|
||||
|
||||
query = (
|
||||
db.query(models.Action)
|
||||
.options(load_only(*ACTION_LIST_COLUMNS))
|
||||
.filter(models.Action.adventure_id == adventure_id)
|
||||
)
|
||||
query = db.query(models.Action).options(load_only(*ACTION_LIST_COLUMNS)).filter(*on_path)
|
||||
if before_id is not None:
|
||||
anchor = (
|
||||
db.query(models.Action.index)
|
||||
.filter(models.Action.id == before_id,
|
||||
models.Action.adventure_id == adventure_id)
|
||||
db.query(models.Action.depth)
|
||||
.filter(models.Action.id == before_id, *on_path)
|
||||
.scalar()
|
||||
)
|
||||
if anchor is None:
|
||||
# The anchor was deleted (undo, or a turn edited away) while the
|
||||
# reader was scrolling. Nothing older can be identified relative to
|
||||
# a row that no longer exists, so report the end rather than
|
||||
# guessing and handing back a duplicate page.
|
||||
# reader was scrolling, or it belongs to a story this branch is not
|
||||
# on. Nothing older can be identified relative to a row that is not
|
||||
# here, so report the end rather than guessing and handing back a
|
||||
# duplicate page.
|
||||
return [], total, False
|
||||
query = query.filter(models.Action.index < anchor)
|
||||
query = query.filter(models.Action.depth < anchor)
|
||||
|
||||
rows = query.order_by(models.Action.index.desc()).limit(limit + 1).all()
|
||||
rows = (
|
||||
query.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
rows.reverse()
|
||||
@@ -163,13 +170,21 @@ def _snippet(text: str) -> str:
|
||||
NARRATION_TYPES = ("ai", "story", "start")
|
||||
|
||||
|
||||
def _latest_narration(db: Session, adventure_ids: list[int]) -> dict[int, str]:
|
||||
def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]:
|
||||
"""Map adventure id -> text of its most recent narrated action.
|
||||
|
||||
One window-function query rather than a per-adventure lookup, so the list
|
||||
endpoint stays at a fixed number of round trips.
|
||||
|
||||
Scoped by head *branch* rather than by the full lineage, which is the one
|
||||
place in the codebase that is allowed to be: a lineage clause per adventure
|
||||
would put a hundred OR-terms on the index screen's query to pick one row
|
||||
each. The two answers differ only for a branch with no nodes of its own,
|
||||
and a branch is created by playing a turn onto it, so that state does not
|
||||
exist. An adventure with no branch at all has no story to quote.
|
||||
"""
|
||||
if not adventure_ids:
|
||||
branch_ids = [b for b in head_branches.values() if b is not None]
|
||||
if not branch_ids:
|
||||
return {}
|
||||
ranked = (
|
||||
db.query(
|
||||
@@ -178,12 +193,13 @@ def _latest_narration(db: Session, adventure_ids: list[int]) -> dict[int, str]:
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=models.Action.adventure_id,
|
||||
order_by=(models.Action.index.desc(), models.Action.id.desc()),
|
||||
order_by=(models.Action.depth.desc(), models.Action.id.desc()),
|
||||
)
|
||||
.label("rank"),
|
||||
)
|
||||
.filter(
|
||||
models.Action.adventure_id.in_(adventure_ids),
|
||||
models.Action.adventure_id.in_(list(head_branches)),
|
||||
models.Action.branch_id.in_(branch_ids),
|
||||
models.Action.type.in_(NARRATION_TYPES),
|
||||
)
|
||||
.subquery()
|
||||
@@ -206,6 +222,7 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs
|
||||
models.Adventure.scenario_id,
|
||||
models.Adventure.title,
|
||||
models.Adventure.updated_at,
|
||||
models.Adventure.head_branch_id,
|
||||
func.count(models.Action.id),
|
||||
models.Scenario.title,
|
||||
models.Scenario.image,
|
||||
@@ -230,7 +247,7 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs
|
||||
.order_by(models.Adventure.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
narration = _latest_narration(db, [row[0] for row in rows])
|
||||
narration = _latest_narration(db, {row[0]: row[4] for row in rows})
|
||||
return [
|
||||
schemas.AdventureListItem(
|
||||
id=adv_id,
|
||||
@@ -245,7 +262,11 @@ def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUs
|
||||
image_url=images.public_url(scenario_id, image or "", scenario_updated),
|
||||
icon=icon or "",
|
||||
)
|
||||
for (adv_id, scenario_id, title, updated_at, count,
|
||||
# `count` is every action of the adventure, not of the path. Under one
|
||||
# branch they are the same number; once forking ships the index screen
|
||||
# will overstate a story that has siblings hanging off it, and the fix
|
||||
# belongs with SP5, where a fork can first exist.
|
||||
for (adv_id, scenario_id, title, updated_at, _head_branch_id, count,
|
||||
scenario_title, image, icon, scenario_updated) in rows
|
||||
]
|
||||
|
||||
@@ -385,7 +406,7 @@ def get_adventure(
|
||||
from GET /{id}/actions as they scroll up.
|
||||
"""
|
||||
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||
actions, total, _ = action_window(db, adventure_id)
|
||||
actions, total, _ = action_window(db, adventure)
|
||||
# Hand the response the window as if the relationship had loaded it.
|
||||
# `set_committed_value` is the only way to do this safely: assigning
|
||||
# `adventure.actions = [...]` marks the collection dirty, and the
|
||||
@@ -636,13 +657,19 @@ def next_index(adventure: models.Adventure) -> int:
|
||||
|
||||
|
||||
def last_action(adventure: models.Adventure, db: Session) -> models.Action | None:
|
||||
"""The newest action of any kind, or None. A query rather than
|
||||
`adventure.actions[-1]`, which would load the entire story to look at
|
||||
one row."""
|
||||
"""The newest action of any kind on the story being played, or None.
|
||||
|
||||
A query rather than `adventure.actions[-1]`, which would load the entire
|
||||
story to look at one row — and, since that collection is every branch's
|
||||
actions, would sometimes look at the wrong one.
|
||||
"""
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adventure.id)
|
||||
.order_by(models.Action.index.desc())
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -1061,8 +1088,11 @@ def undo_turn(
|
||||
# consist of rather than the whole story.
|
||||
newest = (
|
||||
db.query(models.Action)
|
||||
.filter(models.Action.adventure_id == adventure.id)
|
||||
.order_by(models.Action.index.desc())
|
||||
.filter(
|
||||
models.Action.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(models.Action),
|
||||
)
|
||||
.order_by(models.Action.depth.desc(), models.Action.id.desc())
|
||||
.limit(2)
|
||||
.all()
|
||||
)
|
||||
@@ -1093,7 +1123,7 @@ def undo_turn(
|
||||
# transcript with this, and the transcript is a window now. Returning
|
||||
# everything here would undo the paging on the one action most likely
|
||||
# to be repeated several times in a row.
|
||||
actions, total, has_more = action_window(db, adventure_id)
|
||||
actions, total, has_more = action_window(db, adventure)
|
||||
return schemas.ActionPage(
|
||||
actions=[schemas.ActionOut.model_validate(a) for a in actions],
|
||||
total=total,
|
||||
@@ -1704,10 +1734,10 @@ def list_actions(
|
||||
is "give me what comes before this". Omit it for the newest window. See
|
||||
action_window for why this anchors on a row rather than an offset.
|
||||
"""
|
||||
get_adventure_or_404(adventure_id, db, user)
|
||||
adventure = get_adventure_or_404(adventure_id, db, user)
|
||||
limit = max(1, min(limit, ACTION_PAGE * 4))
|
||||
actions, total, has_more = action_window(
|
||||
db, adventure_id, before_id=before_id, limit=limit
|
||||
db, adventure, before_id=before_id, limit=limit
|
||||
)
|
||||
return schemas.ActionPage(
|
||||
actions=[schemas.ActionOut.model_validate(a) for a in actions],
|
||||
|
||||
@@ -4,6 +4,7 @@ state and story-card mutations back to the database after each hook."""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..context import history as context_history
|
||||
from .engine import run_hook
|
||||
|
||||
MAX_STORY_CARDS = 5000 # AI Dungeon's per-adventure sanity cap
|
||||
@@ -23,9 +24,13 @@ class ScriptPipeline:
|
||||
return msg if isinstance(msg, str) and msg.strip() else None
|
||||
|
||||
def _history(self) -> list[dict]:
|
||||
# The path, not `adventure.actions` — that collection is every branch's
|
||||
# actions, and this is the documented history API a user script reads.
|
||||
# Handing a script the siblings of the turn it is running on would be
|
||||
# the same bug as building a prompt from them, only user-visible.
|
||||
return [
|
||||
{"text": a.text, "rawText": a.text, "type": a.type}
|
||||
for a in self.adventure.actions
|
||||
for a in context_history.story_actions(self.adventure)
|
||||
]
|
||||
|
||||
def _cards(self) -> list[dict]:
|
||||
@@ -36,7 +41,7 @@ class ScriptPipeline:
|
||||
|
||||
def _info(self) -> dict:
|
||||
return {
|
||||
"actionCount": len(self.adventure.actions),
|
||||
"actionCount": context_history.count(self.adventure),
|
||||
"characterNames": [],
|
||||
"memoryLength": len(self.adventure.memory),
|
||||
"maxChars": 0,
|
||||
|
||||
+57
-9
@@ -7,16 +7,22 @@ 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" —
|
||||
lands beside it in SP2 (`context/lineage.py`). Nothing here is read yet.
|
||||
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
|
||||
from sqlalchemy import func, insert, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
@@ -45,12 +51,26 @@ def root_branch(db: Session, adventure: models.Adventure) -> models.Branch:
|
||||
)
|
||||
if branch is not None:
|
||||
return branch
|
||||
branch = models.Branch(adventure_id=adventure.id, lineage=[])
|
||||
db.add(branch)
|
||||
# The lineage names the branch's own id, so the row has to exist first.
|
||||
db.flush()
|
||||
branch.lineage = [[branch.id, 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:
|
||||
@@ -81,7 +101,7 @@ def place_action(
|
||||
if action.depth is None:
|
||||
action.depth = action.index
|
||||
adventure.head_branch_id = branch.id
|
||||
if action.depth > adventure.head_depth:
|
||||
if action.depth is not None and action.depth > adventure.head_depth:
|
||||
adventure.head_depth = action.depth
|
||||
return branch
|
||||
|
||||
@@ -102,6 +122,34 @@ def place_memory(
|
||||
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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user