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
@@ -30,6 +30,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from app import models, worldstate
|
||||
from app import narrative
|
||||
from app.context import builder
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
@@ -65,6 +66,13 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
|
||||
|
||||
# ------------------------------------------------------------ prompt layout
|
||||
|
||||
NARRATIVE = {
|
||||
"version": 1, "entities": {}, "possessions": {}, "threads": {},
|
||||
"relationships": [], "scene": {},
|
||||
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
|
||||
}
|
||||
|
||||
|
||||
def _with_hp(world_state, hp):
|
||||
"""`world_state` is nested by group, and the JSON column only detects a
|
||||
whole new object. Build a new one instead of mutating in place."""
|
||||
@@ -91,6 +99,7 @@ def story():
|
||||
ai_instructions="Write in second person.",
|
||||
story_summary="The hero left the village.",
|
||||
world_state=worldstate.instantiate(SCHEMA),
|
||||
narrative_state=NARRATIVE,
|
||||
# Phase 18. Set here so that every test in this file runs with a
|
||||
# persona present: it is user-only, so it belongs in the static block,
|
||||
# and this is the file that guards what may live there.
|
||||
@@ -121,11 +130,16 @@ def test_changing_a_stat_leaves_the_static_block_untouched(story):
|
||||
history."""
|
||||
db, adventure, settings = story
|
||||
before, _, _ = builder.build_context(adventure, settings)
|
||||
adventure.world_state = _with_hp(adventure.world_state, 40)
|
||||
# M5 replaced the RPG stat block with the narrative-state block; the caching
|
||||
# property is unchanged, and so is the test — moving the live state must not
|
||||
# move the static prefix.
|
||||
adventure.narrative_state = {**NARRATIVE, "facts": [
|
||||
{"id": "f1", "predicate": "the lantern has gone out", "status": "active"}]}
|
||||
db.commit()
|
||||
after, story_text, _ = builder.build_context(adventure, settings)
|
||||
assert before == after
|
||||
assert "hp 40/100" in story_text, "the new value still has to reach the model"
|
||||
assert "the lantern has gone out" in story_text, \
|
||||
"the new value still has to reach the model"
|
||||
|
||||
|
||||
def test_the_static_block_holds_the_things_that_do_not_move(story):
|
||||
@@ -134,10 +148,11 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
|
||||
for fixed in ("Write in second person.", "The hero hunts bandits.",
|
||||
"You are Kaelen (he/him).", "A half-elf ranger."):
|
||||
assert fixed in system_text
|
||||
# The stat guide is derived from the schema, so it is fixed. The live
|
||||
# values it describes are not fixed, and belong to the story text.
|
||||
assert "Stat guide" in system_text
|
||||
for moves in ("The hero left the village.", "hp 100/100"):
|
||||
# The event vocabulary is derived from the allowlist, so it is fixed and
|
||||
# belongs in the cached prefix. The state it describes is not fixed, and
|
||||
# belongs to the story text.
|
||||
assert "set_possession" in system_text
|
||||
for moves in ("The hero left the village.", "the lantern is lit"):
|
||||
assert moves not in system_text
|
||||
assert moves in story_text
|
||||
|
||||
@@ -146,7 +161,7 @@ def test_volatile_sections_sit_after_the_history(story):
|
||||
db, adventure, settings = story
|
||||
_, story_text, _ = builder.build_context(adventure, settings)
|
||||
history_at = story_text.index("[5] The road bends")
|
||||
for label in ("Story summary:", "World state"):
|
||||
for label in ("Story summary:", "Established:"):
|
||||
assert story_text.index(label) > history_at, label
|
||||
|
||||
|
||||
@@ -157,10 +172,10 @@ def test_the_tail_stays_the_tail(story):
|
||||
db, adventure, settings = story
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
assert labels[-1] == "world_state_reminder"
|
||||
assert labels[-1] == "state_reminder"
|
||||
assert labels[-2] == "length_hint"
|
||||
assert labels.index("world_state") < labels.index("length_hint")
|
||||
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
|
||||
assert labels.index("narrative_state") < labels.index("length_hint")
|
||||
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
|
||||
|
||||
|
||||
def test_live_sections_are_still_charged_to_the_budget(story):
|
||||
|
||||
Reference in New Issue
Block a user