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
311 lines
14 KiB
Python
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
|
|
}
|