M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.
This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:
visible active transcript position == stored head == authoritative state
Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)
A narrator edit no longer rewrites a row. It returns to the state before the
turn, takes the reader's exact text as the accepted narration, re-derives the
state that text implies, and becomes a new active continuation — while the
original narration keeps its words, its live flag and its whole future as
retained history. At the tip the correction is another take; with story below
it, it forks. No new history machinery: this is the existing fork/take/head
path with the reader's text in place of a generated reply. The §14A refusal
is therefore gone for narrator turns, and remains only for player input.
Pre-M5 positions
Migration 88 backfills the empty narrative document onto every action written
before M5, and a missing snapshot now restores the empty document instead of
leaving the previous position's state standing. Restoring to an old Save
Point no longer leaves a later position's entities and facts on screen.
Narrator context
Replayed history carries prose only; the machine-readable block is no longer
reconstructed into past turns, where it contradicted the authoritative state
in the same prompt. A fact withdrawn by a manual correction is now named as
no longer true, with the reader's reason, rather than silently dropped.
Also
- state_changes joins the action-list bulk read, removing one query per row.
- Extraction takes only the application's own protocol payload: an ordinary
```json or ```python block in a story survives, and a mangled proposal
still does not reach the reader.
Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
+49
-7
@@ -38,6 +38,7 @@ from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import models
|
||||
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
|
||||
@@ -45,7 +46,7 @@ from .context import lineage
|
||||
# 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", "raw_output", "usage")
|
||||
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
@@ -152,26 +153,67 @@ def preceding(
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Restores the world state that `node` left behind.
|
||||
"""Restores the state that `node` left behind.
|
||||
|
||||
A NULL snapshot means leave the live state as it is, never reset it. Rows
|
||||
written before SP4 that the migration could not derive an outcome for carry
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
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()
|
||||
)
|
||||
# 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."""
|
||||
"""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(
|
||||
|
||||
Reference in New Issue
Block a user