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

398 lines
15 KiB
Python

"""Reading the story without reading all of it.
`story_actions()` walked `adventure.actions`, which loads every row of the
adventure — then every caller threw almost all of it away. The context builder
concatenates the story and immediately cuts it back to the token budget; the
NPC-in-scene check looks at the last 6; memory retrieval looks at the last 4;
the post-turn cursor clamp only wants a count. So a turn on a 200-action
adventure read ~840 KB to use maybe 70 KB of it, and the cost grew with every
turn played.
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.
Three rules hold everything together:
* **One definition of "story action".** The cursors in memorybank are
*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.
* **Never load twice.** If `adventure.actions` is already in memory (the
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
# average action length just measured rather than by blind doubling — guessing
# high means reading hundreds of actions to use sixty of them.
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.
SQLite and Postgres both accept single-argument `trim()`, but it strips
spaces only — Python's `.strip()` also drops newlines and tabs, and an
action of nothing but a newline would otherwise count as story text here
and not in Python. `replace()` and `trim()` are the two string functions
both dialects spell identically, so fold the other whitespace into spaces
first. (Form feed and vertical tab are not covered; nothing produces them.)
"""
folded = column
for char in ("\n", "\r", "\t"):
folded = func.replace(folded, char, " ")
return func.trim(folded)
_STORY_TEXT = _sql_stripped(models.Action.text) != ""
def is_story_text(text: str) -> bool:
"""The Python half of `_STORY_TEXT` — keep the two in step."""
return bool(text.strip())
def _loaded_actions(adventure: models.Adventure) -> list[models.Action] | None:
"""The adventure's actions if they are already in memory, else None.
Slicing an already-loaded collection is free; issuing a query beside it
would mean paying for the same rows twice.
"""
state = sa_inspect(adventure)
if state.detached or "actions" in state.unloaded:
return None
return list(adventure.actions)
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
db = _session(adventure)
if db is None:
return None
path = lineage.path_of(db, adventure)
rows = [
a for a in loaded
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,
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,
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, path, exclude_action_id, entries))
.options(defer(models.Action.reasoning))
)
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
subquery, so the emitted SQL names every column — including the deferred
ones this whole design exists to keep off the wire. No bytes come back
either way, but the database still has to read them, and an egress guard
that greps the SQL cannot tell the two apart.
"""
return db.query(func.count(models.Action.id)).filter(
*_filters(adventure, path, exclude_action_id)
)
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(
adventure: models.Adventure, exclude_action_id: int | None = None
) -> list[models.Action]:
"""Every story action, oldest first.
Still the right call where the whole story is genuinely wanted — user
scripts receive it, per AI Dungeon's scripting API. Prefer `tail`, `slice_`
or `count` anywhere the caller only needs part of it.
`exclude_action_id` drops one action from the story — used by retry, where
the row being regenerated is still attached to the adventure (it holds the
variant history) but must not appear in the context assembled to replace it.
"""
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
return in_memory
db = _session(adventure)
if db is None:
return []
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:
"""How many story actions there are, without fetching any of them."""
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
return len(in_memory)
db = _session(adventure)
if db is None:
return 0
return (
_count_query(db, adventure, _path(db, adventure), exclude_action_id).scalar()
or 0
)
def tail_range(
adventure: models.Adventure,
skip: int,
limit: int,
exclude_action_id: int | None = None,
) -> list[models.Action]:
"""`limit` story actions ending `skip` actions before the end, oldest first.
`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 []
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
stop = len(in_memory) - skip
return in_memory[max(stop - limit, 0):stop] if stop > 0 else []
db = _session(adventure)
if db is None:
return []
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
def tail(
adventure: models.Adventure, limit: int, exclude_action_id: int | None = None
) -> list[models.Action]:
"""The newest `limit` story actions, returned oldest first."""
return tail_range(adventure, 0, limit, exclude_action_id)
def slice_(
adventure: models.Adventure,
start: int,
length: int,
exclude_action_id: int | None = None,
) -> list[models.Action]:
"""Story actions at positions [start, start + length), oldest first.
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 []
in_memory = _from_memory(adventure, exclude_action_id)
if in_memory is not None:
return in_memory[start:start + length]
db = _session(adventure)
if db is None:
return []
return (
_query(db, adventure, _path(db, adventure), exclude_action_id)
.order_by(*_OLDEST_FIRST)
.offset(start)
.limit(length)
.all()
)
def position_of_index(adventure: models.Adventure, index: int) -> int:
"""The position the story action with `Action.index == index` occupies —
i.e. how many story actions come before it.
Translates between the two coordinate systems that keep tripping this code
up: cursors are positions, `Memory.source_start/_end` are `Action.index`
values, and the two diverge the moment anything is deleted.
"""
in_memory = _from_memory(adventure, None)
if in_memory is not None:
return next(
(i for i, a in enumerate(in_memory) if a.index >= index), len(in_memory)
)
db = _session(adventure)
if db is None:
return 0
return (
_count_query(db, adventure, _path(db, adventure), None)
.filter(models.Action.index < index)
.scalar()
or 0
)
def max_action_index(adventure: models.Adventure) -> int:
"""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)
db = _session(adventure)
if db is None:
return -1
highest = (
db.query(func.max(models.Action.index))
.filter(models.Action.adventure_id == adventure.id)
.scalar()
)
return -1 if highest is None else highest
def window_covering(
adventure: models.Adventure,
budget_tokens: int,
token_counter,
exclude_action_id: int | None = None,
) -> list[models.Action]:
"""The newest story actions whose combined text exceeds `budget_tokens` —
i.e. more than the context builder can possibly include, and never less.
Measures rather than guesses a chars-per-token ratio, so the prompt is
byte-for-byte what loading the whole story would have produced. Budgets on
the raw text, which is never longer than the rendered history text, so
erring here can only mean fetching slightly too much.
Each round fetches only the actions it doesn't already hold, so no row is
ever read twice however many rounds it takes.
"""
actions: list[models.Action] = []
tokens = 0
size = WINDOW_START
while True:
older = tail_range(
adventure, len(actions), size - len(actions), exclude_action_id
)
if not older:
return actions # already holding the whole story
actions = older + actions
tokens += sum(token_counter(a.text) for a in older)
if len(actions) < size:
return actions # that was the whole story
if tokens > budget_tokens:
return actions
# Short. Project how many actions the budget takes at the length these
# ones turned out to be, and go straight there.
average = tokens / len(actions)
projected = int(budget_tokens / average * (1 + WINDOW_MARGIN)) + WINDOW_STEP
size = max(projected, size + WINDOW_STEP)