Files
JesseMarkowitzandClaude Opus 5 a6e9c7a32b M6: branch-safe context, summaries and long-term story memory
Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.

This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.

What was already correct, and was kept rather than rebuilt

  Memory lineage. Memories already carried (branch_id, depth) and retrieval
  already filtered through the capped-path clause; the ten-step negative control
  was measured passing against b7005e6 before any change here. M6 adds the
  regression tests that pin it, plus provenance and authority on the result.

Summary lineage — both halves

  A summary is a row carrying the coordinate of the last node it covers, and
  eligibility is the same head-capped lineage clause memories use. That alone
  was not enough: generation was seeded from adventures.story_summary, a
  campaign-global column with no lineage, so after a divergence the summariser
  was handed the abandoned line's prose and asked to update it. The row it
  produced was correctly anchored and therefore looked safe while its sentences
  described a story the reader had left.

  Generation is now seeded from summaries.current — the same question the
  context builder asks — so the input and the output are scoped by one rule.
  adventures.story_summary remains a reader-facing mirror for the Plot panel and
  the export bundle, kept in step when a summary is written and when the head
  moves, and nothing authoritative reads it.

Retrieval redundancy

  With a real embedding model, four near-identical memories crowded out the one
  distinctive clue, which survived only because the default memory_top_k is 5.
  Retrieval now drops a candidate that repeats one already chosen, never across
  authority classes, at a threshold measured against the configured embedding
  model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
  the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
  and are recorded as such.

Memory authority, budgeting, observability

  Memory.authority is accepted_story or heuristic, classified by the application
  and marked in the prompt; retrieval never writes state. The reply is reserved
  out of the context budget, and an impossible configuration fails clearly
  instead of overflowing. Each derived pass records ok/idle/failed per campaign,
  served by GET /adventures/{id}/derived and shown in Insights, so the M2
  failure — a dead memory bank with a green suite — is visible if it recurs.
  Provider-wiring tests mock no factory.

Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.

Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-06 03:00:33 -04:00

311 lines
14 KiB
Python

"""Phase 14, SP4: the attempts at one turn.
A retry used to rewrite the AI action in place and append the discarded attempt
to a JSON list on the same row. Seven separate bugs came from that arrangement.
The row's `text` duplicated one entry of a repeating group, a second column
duplicated its length, and every reader that touched the story during a retry
had to be told to ignore the row.
Now an attempt is a node. A retry writes a sibling at the same `(branch_id,
depth)` and marks it live. The previous attempt stays as it was written, at the
same coordinate, with `live = False`. Nothing is duplicated, so nothing can
diverge.
Two invariants hold the arrangement together, and this module is the only place
that maintains either one:
* Exactly one sibling in a group is live. `lineage.Path.clause` selects on it, so
the other attempts are invisible to every read of the story, and none of those
reads has to know that attempts exist.
* The assembled prompt is stored once per turn, on the live sibling. A
`context_snapshot` is about 163 kB of prompt that every attempt at a turn
shares, plus a few hundred bytes that differ, listed in `ATTEMPT_KEYS`. Giving
each sibling its own copy would make a retry a permanent multiplier on the
largest column in the database, which is what the JSON list was invented to
avoid. The prompt therefore moves with the live flag, and a superseded sibling
keeps only its own slices.
Ordering inside a group comes from `id`, not from `created_at`. Two attempts made
in the same second still have to page in the order they were made, and `id`
increases with every insert. SP8 dropped `variant_index`, an explicit ordinal
that carried the same order, once a run of the suite confirmed that the two
agreed in every group.
"""
import copy
from sqlalchemy.orm import Session, object_session, undefer
from . import models, summaries
from .context import lineage
from .narrative import model as narrative_model
# The slices of a context snapshot that belong to one attempt rather than to the
# turn. They are the world-state delta the attempt proposed and what the engine
# did with it, the model's literal reply, and the endpoint's
# token accounting. Each attempt is its own API call, and a retry is the call
# most likely to read the prompt back out of cache. Everything else in a snapshot
# is the prompt, which is assembled once per turn.
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
# ------------------------------------------------------------------ reading
def group(db: Session, action: models.Action) -> list[models.Action]:
"""Returns every attempt at `action`'s turn, oldest first.
The query keys on the parent rather than on the coordinate (SP9). The two
agree until an attempt is forked onto its own branch. That attempt keeps its
parent but leaves the `(branch, depth)` its siblings are still at, so a
coordinate would report it as the only attempt at its turn, showing `1/1`
where the player should see `1/3`.
The parent also nests groups correctly without extra work. Attempts under C1
and attempts under C2 share a depth, and until one of them forks they share a
branch. Only the parent separates them, which is what makes a pager under C2
read `2/2` rather than count C1's three as well.
There are two fallbacks, and both mean the row predates the key being asked
about. A node with no branch is a pre-tree row that no path contains, and a
node with no parent is a pre-SP9 row the backfill could not place. Under the
rule each was written with, both are the only attempt at their turn.
"""
if action.branch_id is None or action.depth is None:
return [action]
if action.parent_id is None:
# This row is pre-SP9, and the coordinate is the key those rows were
# written under. A root node also reaches this branch and is genuinely
# alone, because nothing is an attempt at the opening of a story.
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == action.adventure_id,
models.Action.branch_id == action.branch_id,
models.Action.depth == action.depth,
models.Action.parent_id.is_(None),
)
.order_by(models.Action.id)
.all()
)
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == action.adventure_id,
models.Action.parent_id == action.parent_id,
)
.order_by(models.Action.id)
.all()
)
def on_branch(rows: list[models.Action], node: models.Action) -> list[models.Action]:
"""Returns the attempts in `rows` that are on `node`'s own branch.
`group` reports which attempts belong to this turn, and since SP9 that spans
branches. An attempt forked onto its own line is still an attempt at the same
turn, which is the reason for keying on the parent.
Deletion is the one caller that must not follow a group across branches. An
attempt on another branch is reachable through that branch and belongs to the
story someone is telling there. Removing it because a turn was undone here
would delete a line nobody asked about. The same parent and the same branch
together are the coordinate, which is what every attempt at this turn meant
before a fork could move one out of it.
"""
return [row for row in rows if row.branch_id == node.branch_id]
def live_in(rows: list[models.Action]) -> models.Action | None:
for row in rows:
if row.live:
return row
return None
def preceding(
db: Session, adventure: models.Adventure, node: models.Action
) -> models.Action | None:
"""Returns the node the story tells immediately before `node`.
This reads "before this turn" as a fact about the path rather than as a
snapshot taken from inside the turn, which is what makes the after-snapshots
sufficient on their own. The query undefers both of them, because the only
reason to fetch this row is to restore what it left behind.
"""
if node.depth is None:
return None
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Action),
models.Action.depth < node.depth,
)
.options(
undefer(models.Action.state_after),
undefer(models.Action.world_state_after),
)
.order_by(models.Action.depth.desc(), models.Action.id.desc())
.first()
)
# ------------------------------------------------------------------ writing
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
"""Restores the state that `node` left behind.
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
the same at any distance: the destination node carries its own outcome, so
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
M5 changed what is restored, not how — the narrative state document takes
the place the RPG world state held, through the same single function.
The two columns follow **different** rules about a NULL, and the difference
is not an oversight.
For the narrative document, a NULL means *this position established
nothing*, and it is restored as the empty document. Leaving the live state
alone instead is what the M5 review caught (Finding 3): arriving at a
migrated pre-M5 node left a later position's entities, facts and threads
standing, so the transcript said depth 2 while the state described depth 6.
The invariant this module exists to hold is that the visible position, the
head and the authoritative state agree, and "keep whatever was there" cannot
hold it. An empty document at an old position is honest — the narrative
state system knew nothing then, because it did not exist — where retained
state from elsewhere is a claim about a story that had not been told yet.
Migration backfills those rows explicitly, so this fallback is the belt to
that pair of braces: it also covers a node arriving from an older export,
which the migration never sees.
For the legacy RPG world state a NULL still means leave it alone. Those rows
predate SP4, nothing consults the values to decide anything, and overwriting
a running adventure's numbers with an empty dict would be worse than doing
nothing.
"""
if node is None:
return
adventure.narrative_state = (
copy.deepcopy(node.narrative_state_after)
if isinstance(node.narrative_state_after, dict)
else narrative_model.empty()
)
# M6: the reader-facing summary mirror follows the head too. It is a
# convenience column with no lineage of its own, so without this it would go
# on showing a summary belonging to a position the story has left. Nothing
# authoritative reads it — the prompt takes its summary from
# `summaries.current` — but the Plot panel and the export bundle do.
session = object_session(adventure)
if session is not None:
summaries.refresh_mirror(session, adventure)
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
# coherent with the position being read, so an old save is not left showing
# a future's values. Nothing consults them to decide anything.
if isinstance(node.world_state_after, dict):
adventure.world_state = copy.deepcopy(node.world_state_after)
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
"""Records on `node` the state of the adventure now that the node has played.
Every node, including a player's action that changed nothing. A position
without a snapshot is a position the head cannot be restored to, and the
head can rest on any node.
"""
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
# `state_after` held the scripting engine's shared state, which M2 removed.
# The column stays for schema compatibility and is written empty.
node.state_after = {}
node.world_state_after = copy.deepcopy(world)
narrative = adventure.narrative_state
node.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def roll_back_before(
db: Session, adventure: models.Adventure, node: models.Action
) -> None:
"""Rewinds the shared state to what it was before `node` was played."""
restore_state(adventure, preceding(db, adventure, node))
def add_attempt(
db: Session,
adventure: models.Adventure,
previous: models.Action,
replacement: models.Action,
) -> None:
"""Places `replacement` next to `previous` as the newer attempt at that turn.
The placement is done here rather than through `tree.place_action`, which
moves the head. A sibling is not a new turn. It is another attempt at the
turn the head is already on.
"""
replacement.branch_id = previous.branch_id
replacement.depth = previous.depth
# Copy the parent rather than resolve it from the path. An attempt belongs
# to the turn it is an attempt at, and that is what `group` keys on.
# Resolving it here would ask which node is live one depth back. That is the
# same node right now, and it stops being the same node once the story forks
# away from this turn.
replacement.parent_id = previous.parent_id
replacement.live = True
# The replacement takes its place at the end of the group, because `group`
# orders by `id` and this row has no id yet. Switching a three-attempt turn
# back to attempt 1 and retrying therefore still pages 1, 2, 3, 4, which is
# the order the attempts were made in.
previous.live = False
# The replacement was assembled with a fresh snapshot, so the prompt for
# this turn is now the one it carries. The superseded attempt keeps only the
# slices that were its own.
keep_own_slices(previous)
def make_live(
db: Session, adventure: models.Adventure, node: models.Action
) -> list[models.Action]:
"""Makes `node` the attempt the story tells, and restores its outcome.
Returns the group, so that a caller reporting on it does not read it twice.
"""
rows = group(db, node)
previous = live_in(rows)
if previous is not None and previous is not node:
hand_over_the_prompt(previous, node)
for row in rows:
row.live = row is node
restore_state(adventure, node)
return rows
# ------------------------------------------------- the prompt, stored once
def keep_own_slices(node: models.Action) -> None:
"""Reduces `node`'s snapshot to the slices that are only its own."""
snapshot = node.context_snapshot
if not isinstance(snapshot, dict):
return
node.context_snapshot = {
key: snapshot[key] for key in ATTEMPT_KEYS if key in snapshot
} or None
def hand_over_the_prompt(giver: models.Action, taker: models.Action) -> None:
"""Moves the turn's assembled prompt from one attempt to another.
The caller runs this when the live flag moves, so that the row in the story
is always the row the Insights viewer can explain. Nothing is copied. The
prompt exists once before and once after, on whichever sibling is being read.
"""
held = giver.context_snapshot if isinstance(giver.context_snapshot, dict) else {}
shared = {k: v for k, v in held.items() if k not in ATTEMPT_KEYS}
if not shared:
return
keep_own_slices(giver)
own = taker.context_snapshot if isinstance(taker.context_snapshot, dict) else {}
taker.context_snapshot = shared | {
k: v for k, v in own.items() if k in ATTEMPT_KEYS
}