Files
interactive-story/backend/app/context/cursors.py
Parth e7d75c3b05 Rewrite Python comments in Google developer documentation style (#12)
* Rewrite comments in Google developer documentation style

Rewrite the comments and docstrings across the backend core modules so they
read plainly. The previous prose was accurate but dense and figurative, which
made it slow to skim.

Applies the Google developer documentation style guide: short sentences, active
voice, present tense, American spelling, and no metaphors, idioms, or
rhetorical asides. Replaces em-dash chains with separate sentences.
2026-08-26 15:37:25 +05:30

173 lines
7.5 KiB
Python

"""Phase 14: tracks how far along a story the derived work has reached.
Two things are built from the story and stored beside it: the memories and the
story summary. Both need to record where they stopped.
That mark used to be a count, such as "the first 12 story actions are covered".
A count is a position in a list, and this list changes. If you delete an action
in front of the mark, every later action moves down one slot, so the mark now
covers an action it never read. The rules in `memorybank` for sliding cursors,
rewinding them, and converting between positions and `Action.index` all existed
to correct for that, and each rule was a chance to introduce a silent error.
A cursor here is an anchor instead. It stores `(branch_id, depth)`, naming the
node up to and including which the work is done. Deleting an action does not
move it, because a depth is a coordinate along a path rather than a position in
a list. The question "what is not covered yet" becomes
`history.count_after(anchor)`, which asks about the story rather than about a
list index, and it stays correct no matter what is deleted in front of it.
The branch half of the anchor is what makes it survive forking. A depth alone is
ambiguous once two branches both hold a node at depth 41. The anchor names the
branch, and `Path.depth_on` reads it back as a depth on whichever story is being
played. That read caps the depth at the fork, or reports nothing covered if the
anchor sits on a branch this path does not contain. Until forking ships there is
one branch, so this always returns the stored depth. That is the point: the
coordinate system is correct before anything depends on it.
`NO_DEPTH`, which is -1, means nothing is covered. A new adventure therefore
needs no special case, because every node is deeper than -1.
"""
from sqlalchemy.orm import Session
from .. import models
from . import history, lineage
NO_DEPTH = lineage.NO_DEPTH
class Cursor:
"""One anchor on the adventure row, for either the memory bank or the summary.
The anchor is a pair of columns rather than a foreign key to the node. The
node can be deleted, which is what undo does, and the boundary still means
something afterwards. A foreign key would repeatedly fail to resolve.
"""
def __init__(self, name: str):
self.name = name
self.branch_field = f"{name}_cursor_branch_id"
self.depth_field = f"{name}_cursor_depth"
# ------------------------------------------------------------- reading
def stored(self, adventure: models.Adventure) -> tuple[int | None, int]:
"""Returns the anchor as written, without resolving it against a path."""
depth = getattr(adventure, self.depth_field)
return getattr(adventure, self.branch_field), (
NO_DEPTH if depth is None else depth
)
def depth(self, db: Session, adventure: models.Adventure) -> int:
"""Returns the anchor as a depth on the story being played now."""
branch_id, depth = self.stored(adventure)
return lineage.path_of(db, adventure).depth_on(branch_id, depth)
# ------------------------------------------------------------- writing
def anchor(
self, adventure: models.Adventure, branch_id: int | None, depth: int
) -> None:
"""Sets the anchor to a coordinate supplied directly.
This is the plain setter beneath `anchor_at`. Only an import supplies a
coordinate with no node to read it from. A v2 bundle carries the anchor
itself, as described in `app/bundle.py`, and the node it named lives in
a different database.
"""
setattr(adventure, self.branch_field, branch_id)
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
def anchor_at(self, adventure: models.Adventure, node: models.Action) -> None:
"""Marks the work done up to and including `node`.
This uses the node's own branch rather than the adventure's head. A block
of six actions can end before the fork that created the current branch,
and the coverage belongs where those actions are.
"""
self.anchor(adventure, node.branch_id, lineage.NO_DEPTH
if node.depth is None else node.depth)
def clear(self, adventure: models.Adventure) -> None:
"""Clears the anchor, so that nothing counts as covered.
Call this when the branch the anchor referred to is deleted. On Postgres
a stale branch id would never resolve. On SQLite the next fork can reuse
an id that was just freed, and a stale anchor would then resolve onto a
branch it never saw and report that stretch of story as summarized.
Clearing the anchor costs one re-summarize, which is the safe direction
to be wrong in.
"""
self.anchor(adventure, None, NO_DEPTH)
def rewind_to(
self, adventure: models.Adventure, branch_id: int | None, depth: int
) -> None:
"""Moves the anchor back to `depth` if it is past that depth.
The anchor never moves forward here. Moving backward is the only
direction that is safe without knowing what else changed. Covering
ground twice costs one summarizer call. Skipping ground removes a
stretch of story from the memories permanently.
"""
_, current = self.stored(adventure)
if current <= depth:
return
setattr(adventure, self.branch_field, branch_id)
setattr(adventure, self.depth_field, max(depth, NO_DEPTH))
MEMORY = Cursor("memory")
SUMMARY = Cursor("summary")
ALL = (MEMORY, SUMMARY)
def rewind_all(
adventure: models.Adventure, branch_id: int | None, depth: int
) -> None:
"""Returns a stretch of story to both the memory pass and the summary pass.
The two move together because they cover the same actions from different
directions. The summary folds in the memories, so withdrawing a memory
without rewinding the summary would leave the summary claiming to have read
something that no longer exists.
"""
for cursor in ALL:
cursor.rewind_to(adventure, branch_id, depth)
def anchor_at_position(
adventure: models.Adventure, cursor: Cursor, position: int
) -> None:
"""Sets `cursor` from a count of covered story actions.
A v1 bundle stores its mark as a count, and so does a database written
before the anchors existed.
The action at `position` in depth order is the node that carries the same
meaning, and it keeps that meaning after something in front of it is
deleted. A position past the end of the story is not an invalid value. An
adventure that was fully caught up under the old rule can hold one, and it
means the same thing as the tip, so this function anchors at the tip.
`migrations._backfill_cursor_anchors` implements this rule in SQL for every
adventure at once, without loading any of them. The two must agree.
"""
if position <= 0:
return
covered = history.slice_(adventure, position - 1, 1) or history.tail(adventure, 1)
if covered:
cursor.anchor_at(adventure, covered[0])
def position_of(adventure: models.Adventure, depth: int) -> int:
"""Returns how many story actions lie at or before `depth`.
This reads an anchor back as a count. The v1 export bundle stores cursors as
counts, and builds that have never used depths read v1 bundles. This
function is the only remaining code that speaks that coordinate system. The
v2 format introduced in SP6 replaces it.
"""
return max(history.count(adventure) - history.count_after(adventure, depth), 0)