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:
JesseMarkowitz
2026-09-05 07:01:50 -04:00
co-authored by Claude Opus 5
parent 62a997f364
commit b7005e6fdd
57 changed files with 7257 additions and 474 deletions
+49 -7
View File
@@ -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(
+28
View File
@@ -60,6 +60,7 @@ from sqlalchemy.orm import Session, undefer
from . import attempts, models, schemas
from .context import cursors, lineage
from .narrative import model as narrative_model
FORMAT = "ai-dnd-adventure-v2"
LEGACY_FORMAT = "ai-dnd-adventure-v1"
@@ -121,6 +122,14 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"desc": adventure.persona_desc,
},
"worldState": adventure.world_state,
# M5. The authoritative narrative state, and the campaign's own rules.
# Both are decisions rather than derivations — the state is what the
# campaign established, and canon is what its owner wrote — so both go
# in the file by the rule at the top of this module. A bundle written
# before M5 has neither key and imports with an empty state, which is
# what such a campaign had.
"narrativeState": adventure.narrative_state,
"campaignCanon": adventure.campaign_canon,
"autoSummarize": adventure.auto_summarize,
"memoryBankEnabled": adventure.memory_bank_enabled,
# Write a root entry even for an adventure whose branch row was never
@@ -207,6 +216,14 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
node["stateAfter"] = action.state_after
if action.world_state_after is not None:
node["worldStateAfter"] = action.world_state_after
# M5. Without this a restored campaign could be read but not moved around
# inside: every Undo, Redo and Save Point restore reads the destination
# node's snapshot, so a bundle carrying the turns and not the snapshots
# imports a story whose history cannot be walked.
if action.narrative_state_after is not None:
node["narrativeStateAfter"] = action.narrative_state_after
if action.state_changes:
node["stateChanges"] = action.state_changes
if action.world_delta:
node["worldDelta"] = action.world_delta
return node
@@ -495,6 +512,8 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
"branch": branch,
"depth": depth,
"live": bool(entry.get("live", True)),
"narrativeStateAfter": _as_dict(entry.get("narrativeStateAfter")),
"stateChanges": _as_dict(entry.get("stateChanges")),
"type": str(entry.get("type") or "story")[:TYPE_MAX],
"text": str(entry.get("text") or ""),
"reasoning": _as_text(entry.get("reasoning")),
@@ -718,6 +737,8 @@ def _write_nodes(
state_after=spec["stateAfter"],
world_state_after=spec["worldStateAfter"],
world_delta=spec["worldDelta"],
narrative_state_after=spec.get("narrativeStateAfter"),
state_changes=spec.get("stateChanges"),
)
if spec["createdAt"] is not None:
action.created_at = spec["createdAt"]
@@ -871,6 +892,13 @@ def materialize(
ai_instructions=str(payload.get("aiInstructions") or ""),
story_summary=str(payload.get("storySummary") or ""),
world_state=payload.get("worldState") or {},
# M5. Normalised on the way in, so a hand-edited or truncated state
# section costs the section rather than the campaign — the story is the
# valuable thing, and a malformed document should not refuse an import.
narrative_state=narrative_model.normalize(payload.get("narrativeState"))
if isinstance(payload.get("narrativeState"), dict) else None,
campaign_canon=payload.get("campaignCanon")
if isinstance(payload.get("campaignCanon"), dict) else None,
auto_summarize=bool(payload.get("autoSummarize", False)),
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
**_imported_persona(payload.get("persona")),
+86 -61
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
import tiktoken
from .. import models, worldstate
from .. import models, narrative, worldstate
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
@@ -88,7 +88,7 @@ class Section:
return count_tokens(self.text)
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
def length_hint(max_output_tokens: int) -> str:
"""Ask for a turn that fits inside the output cap, stated as a word budget.
Returns an empty string when the cap is too small to state usefully. The
@@ -98,12 +98,8 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
if words < MIN_LENGTH_HINT_WORDS:
return ""
tail = (
" Finish the narration and append the state block well inside the limit."
if has_ws
else " Bring the turn to a close well inside the limit rather than "
"stopping mid-sentence."
)
tail = " Finish the narration and append the state block well inside the limit."
# State the number as a ceiling, never as a budget. In measurements, the
# wording "keep this turn under about N words" read to the model as a target
# to fill. It raised the average from 174 words to 246 across five runs, and
@@ -166,30 +162,58 @@ def _script_memory(adventure: models.Adventure) -> dict:
def _history_text(action: models.Action) -> str:
"""Returns an AI turn as the model should see it in replayed history.
The result is the narration with its state block appended again,
reconstructed from the stored delta. The app strips that block before
storing and displaying the turn. Without this function, every past AI turn
would appear to have emitted no state, and the model would copy that pattern
and stop emitting state itself. Player turns and turns with no block pass
through unchanged.
Replayed history is **prose only**. The protocol block is not reconstructed
into it, and the M5 corrective pass is why (review Finding 4).
The block replays the changes the engine ACCEPTED, not the ones the model
sent. Replaying what was sent showed the model a refused change standing as
though it had been applied, while the live values in the same prompt
disagreed with it. Nothing marked which of the two was true, so the model
read its own refused change as correct and sent it again.
Replaying the block was meant to teach the model the output format by
example. What it actually did was put a second, older account of the world
into the same prompt as the authoritative one, with nothing marking which
governed. A fact the reader had explicitly withdrawn through a manual
correction was dropped from the state section and then handed straight back
in the history section, as an accepted event, phrased exactly as the model
had first asserted it. C04 requires a correction to reach the narrator's
context; a correction the next prompt contradicts has not reached it.
This function reads `world_delta` rather than `context_snapshot`. It runs
for every action in the replayed history, and `context_snapshot` is deferred
so that a turn never loads the prompt archive from the database.
Two other things were wrong with it. The blocks are implementation
metadata, not story, and every other consumer of stored text — memory,
summaries, export, the transcript — treats an action's text as prose. And a
turn's accepted events are a record of what was true *then*, which is
precisely what a later correction, retcon or invalidation revises.
The format instruction survives without the examples: `EMIT_RULE` carries a
worked example in the system block and `EMIT_REMINDER` repeats the demand
last, where recency is strongest.
"""
text = action.text
wd = action.world_delta if isinstance(action.world_delta, dict) else None
if wd:
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
if block:
text = f"{text}\n{block}"
return text
return action.text
def _canon_section(adventure: models.Adventure) -> str:
"""The campaign's own rules, rendered for the system block.
Canon is configuration (C01, J03): the campaign writes what is true and what
is forbidden, and both the prompt and the validator read the same field.
Putting it in the system block is what makes C01 a narration-time constraint
as well as a validation-time one — the model is told the rule rather than
only refused after breaking it.
"""
canon = adventure.campaign_canon
if not isinstance(canon, dict):
return ""
lines: list[str] = []
rules = canon.get("rules")
if isinstance(rules, list):
lines += [f"- {rule}" for rule in rules if isinstance(rule, str) and rule.strip()]
forbidden = canon.get("forbidden_status_changes")
if isinstance(forbidden, list):
for rule in forbidden:
if isinstance(rule, dict) and rule.get("from") and rule.get("to"):
lines.append(
f"- Nothing that is {rule['from']} can become {rule['to']}."
)
if not lines:
return ""
body = "\n".join(lines)
return f"Campaign canon (these are true and may not be contradicted):\n{body}"
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
@@ -259,11 +283,14 @@ def build_context(
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
has_ws = worldstate.has_schema(stat_schema)
persona_name = adventure.persona_name.strip()
if has_ws:
guide = worldstate.render_reference(stat_schema, persona_name)
if guide:
system_sections.append(Section("world_state_guide", guide))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
# M5: the typed-event protocol replaces the delta rule for every campaign,
# with or without an inherited stat schema. State is no longer an opt-in
# RPG layer — a story has entities, places and possessions whatever genre it
# is, so the rule is unconditional.
system_sections.append(Section("state_rule", narrative.extract.EMIT_RULE))
canon_text = _canon_section(adventure)
if canon_text:
system_sections.append(Section("campaign_canon", canon_text))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
@@ -301,21 +328,20 @@ def build_context(
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
world_state_section = None
refusal_note = ""
if has_ws:
# One read serves both the in-scene NPCs and the refusal note below.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
persona_name,
)
if block:
world_state_section = Section("world_state", block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = worldstate.render_refusals(last_ai.world_delta)
# M5: the authoritative narrative state, as the model is shown it. Read from
# the campaign's live document, which head movement keeps pointed at the
# position being read — so an undone story is described by the state it had
# then, not by the state it reached later.
state_block = narrative.render.for_prompt(adventure.narrative_state)
if state_block:
world_state_section = Section("narrative_state", state_block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = narrative.extract.render_rejections(last_ai.state_rejections)
authors_note_text = adventure.authors_note.strip()
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
@@ -326,7 +352,7 @@ def build_context(
if isinstance(script_mem.get("frontMemory"), str):
front_memory = script_mem["frontMemory"].strip()
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
length_note = length_hint(settings.max_output_tokens)
# The live sections sit below the history, but they are still part of the
# prompt, so they still count against the budget. `world_lore` is the
@@ -341,7 +367,7 @@ def build_context(
+ count_tokens(authors_note)
+ count_tokens(front_memory)
+ count_tokens(length_note)
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
+ count_tokens(narrative.extract.EMIT_REMINDER)
+ count_tokens(refusal_note)
)
available = max(256, settings.context_token_budget - reserved)
@@ -386,7 +412,7 @@ def build_context(
for action in reversed(actions):
# Budget against the text as it appears in the prompt, which includes
# the state block when this adventure tracks world state.
rendered = _history_text(action) if has_ws else action.text
rendered = _history_text(action)
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
if spent + tokens > history_budget:
if not included_actions:
@@ -407,7 +433,7 @@ def build_context(
# ----- Assemble the story text, with the author's note near the end -----
# Append each AI turn's state block again. The app strips it before storage,
# and the recent history has to show the model the pattern to follow.
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
texts = [_history_text(a) for a in included_actions]
note_sections: list[Section] = []
if authors_note:
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
@@ -431,14 +457,13 @@ def build_context(
# applies to the block that follows it, so this is also the order in which
# the model acts.
note_sections.append(Section("length_hint", length_note))
if has_ws:
# A correction for the previous turn sits directly above the reminder
# to emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("world_state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
# A correction for the previous turn sits directly above the reminder to
# emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("state_reminder", narrative.extract.EMIT_REMINDER))
story_sections = [s for s in note_sections if s.text]
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
+74 -3
View File
@@ -361,6 +361,47 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# inventing one would be inventing the decision.
(80, "CREATE INDEX IF NOT EXISTS ix_checkpoints_adventure "
"ON checkpoints (adventure_id)"),
# M5: genre-neutral authoritative narrative state. `create_all` builds the
# two new tables — `state_proposals` and `state_events` — as it did
# `memories`, `branches` and `checkpoints`; these are the columns it cannot
# add to tables that already exist, plus the indexes the audit reads need.
#
# **No backfill, deliberately.** The inherited RPG world state is numbers
# against a stat schema: `player.gold = 70`, `npc.gwen.trust = 3`. Nothing
# in that says who Gwen is, where anyone stands, or what anyone holds, and a
# narrative fact invented from a number would be fiction the campaign never
# established — exactly what the M5 brief forbids. So the old columns are
# left intact and non-authoritative, and every campaign starts M5 with an
# empty narrative state that its next turns fill in.
#
# The campaign's own `narrative_state` is left NULL: an adventure with no
# M5 turns yet has no document, and the first one writes it.
#
# Per-action snapshots are a different question, and the M5 corrective pass
# settled it the other way (review Finding 3). This block originally left
# those NULL too, reasoning that an empty document would be "a claim, not an
# absence". The consequence was worse than the claim: restoring to an old
# position left the state of a *later* position standing, so the transcript
# and the state described different moments. Backfilling the empty document
# at version 88 says the only true thing about a pre-M5 position — the
# narrative-state system established nothing there, because it did not yet
# exist — and keeps head, transcript and state in agreement. The legacy RPG
# columns are untouched and still restored beside it.
(81, "ALTER TABLE adventures ADD COLUMN narrative_state BLOB"),
(82, "ALTER TABLE adventures ADD COLUMN campaign_canon JSON"),
(83, "ALTER TABLE actions ADD COLUMN narrative_state_after BLOB"),
(84, "ALTER TABLE actions ADD COLUMN state_changes JSON"),
(85, "CREATE INDEX IF NOT EXISTS ix_state_events_adventure "
"ON state_events (adventure_id, id)"),
(86, "CREATE INDEX IF NOT EXISTS ix_state_events_action "
"ON state_events (action_id)"),
(87, "CREATE INDEX IF NOT EXISTS ix_state_proposals_adventure "
"ON state_proposals (adventure_id, id)"),
# M5 corrective pass. No DDL — 83 already added the column. This version
# exists to carry the data pass that fills it in for rows that predate it,
# so that every position an existing campaign can be restored to has a
# snapshot. See `_backfill_narrative_snapshots`.
(88, "-- narrative snapshot backfill (data pass only)"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -374,6 +415,7 @@ TREE_BACKFILL_VERSION = 52
CURSOR_ANCHOR_VERSION = 56
SIBLING_SPLIT_VERSION = 60
PARENT_BACKFILL_VERSION = 64
NARRATIVE_SNAPSHOT_VERSION = 88
# An adventure with no actions has no tip. A value of -1 keeps the rule that the
# next node goes at `head_depth + 1` true without a special case. This matches
@@ -391,6 +433,30 @@ SNAPSHOT_BATCH = 50
BACKFILL_BATCH = 200
def _backfill_narrative_snapshots(conn) -> None:
"""Gives every pre-M5 action the empty narrative document as its outcome.
One statement, no row loop: the document is identical for every row, so it
is encoded once in Python and bound as a single parameter. `narrative.model`
owns the shape and `compression.pack` owns the encoding, so this cannot
drift from what `snapshot_outcome` writes.
Why the empty document rather than NULL is argued at migration 81. In short:
a position with no snapshot used to mean "leave the live state alone", which
let a later position's state stand while the reader was somewhere else.
"""
from .compression import pack
from .narrative import model as narrative_model
conn.execute(
text(
"UPDATE actions SET narrative_state_after = :document "
"WHERE narrative_state_after IS NULL"
),
{"document": pack(narrative_model.empty())},
)
def _backfill_world_delta(conn) -> None:
"""Populates `actions.world_delta` from the existing `context_snapshot`.
@@ -1095,9 +1161,12 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
if current < version <= through:
statement = _for_dialect(sql, conn.dialect.name)
# Skip the DDL when it has already run. The data pass below it
# still runs.
if not (_column_already_there(conn, statement)
or _column_already_gone(conn, statement)):
# still runs. A version whose whole content is a data pass
# carries a comment in place of DDL and executes nothing.
if not statement.lstrip().startswith("--") and not (
_column_already_there(conn, statement)
or _column_already_gone(conn, statement)
):
conn.execute(text(statement))
if version == WORLD_DELTA_VERSION:
_backfill_world_delta(conn)
@@ -1127,5 +1196,7 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
# exist.
if version == PARENT_BACKFILL_VERSION:
_backfill_parents(conn)
if version == NARRATIVE_SNAPSHOT_VERSION:
_backfill_narrative_snapshots(conn)
current = version
_set_version(conn, current)
+177
View File
@@ -119,7 +119,28 @@ class Adventure(Base):
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
#
# **Legacy as of M5**, and no longer authoritative. M5 replaced the
# relative-delta protocol this column served (ADR 010); the turn engine no
# longer writes it, and nothing reads it to decide anything. It stays so
# that a pre-M5 database opens unchanged and its numbers remain visible to
# whoever wants to look — `narrative_state` below is what the story means
# now. Reinterpreting these values as generic narrative facts would be
# inventing meaning the data does not carry, which the M5 brief forbids.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# M5: the authoritative narrative state, as it stands at the active head.
# Genre-neutral (ADR 006), written only by validated typed events (ADR 010),
# and restored from the destination node's snapshot whenever the head moves,
# so it always describes the story being read rather than a story the reader
# has stepped back from.
narrative_state: Mapped[dict] = mapped_column(
CompressedJSON, nullable=True, default=None
)
# Campaign canon: rules the story may not contradict, as configuration
# rather than code (C01, J03). A fantasy campaign forbidding resurrection
# and a science-fiction one forbidding faster-than-light travel use the same
# field and the same validator; neither word appears in the application.
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
@@ -302,6 +323,97 @@ class Checkpoint(Base):
)
class StateProposal(Base):
"""M5: what the model proposed, and what the application did about it.
`DATA-MODEL.md` §19 requires the model's proposal to be *distinct from*
accepted state, and this table is that separation made physical. The model
writes here; it never writes `state_events`, and it never writes a snapshot.
A row exists whether the proposal was accepted, partly accepted, rejected or
unparseable. A rejected proposal is not authoritative and changes nothing,
but it is the record that explains why the state does not say what the
narration seems to say — without it, a wrong-looking campaign has no trail
to follow. `raw_output` is kept for exactly the case that matters most: the
block that did not parse, which no structured column could hold.
"""
__tablename__ = "state_proposals"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
# The node whose narration produced this. NULL only for a manual correction,
# which has a coordinate but no narration behind it.
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Which model produced it, so a later comparison of extraction quality has
# something to group by. Empty for a manual correction.
model_name: Mapped[str] = mapped_column(String(200), default="")
# `accepted_story` or `manual_correction` — who is asserting this.
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
# accepted | partially_accepted | rejected | unparseable
status: Mapped[str] = mapped_column(String(30), default="accepted")
# The block as written, including when it did not parse.
raw_output: Mapped[str] = mapped_column(Text, default="")
# The parsed payload, the events accepted, and every rejection with its
# reason. Compressed for the same reason the prompt is: a busy turn's
# rejections are the largest thing here and nothing reads them in bulk.
detail: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class StateEvent(Base):
"""M5: one accepted change to the authoritative narrative state.
The audit half of `DATA-MODEL.md` §17's hybrid. Append-only, ordered, and
**never read to reconstruct state** — that is the snapshot's job, and mixing
the two would make restore proportional to campaign length, which ADR 012
and M4 both forbid.
What this table answers is §8's list: what changed, why, which turn caused
it, whether a model or the user asserted it, and what the value was before.
`before` is stored per event rather than derived, because deriving it would
mean replaying — the thing the hybrid exists to avoid.
Events carry the story coordinate as well as the action id. The coordinate
survives a retry replacing the live take at that position, exactly as a Save
Point's does; the action id says which attempt actually proposed it.
"""
__tablename__ = "state_events"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
proposal_id: Mapped[int | None] = mapped_column(
ForeignKey("state_proposals.id", ondelete="SET NULL"), nullable=True
)
action_id: Mapped[int | None] = mapped_column(
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
)
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Order within one proposal, so a turn's events replay for a reader in the
# order they were applied.
sequence: Mapped[int] = mapped_column(Integer, default=0)
event_type: Mapped[str] = mapped_column(String(60), default="")
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# What the affected value was immediately before this event, so the audit
# can answer "what did it used to be" without reconstruction. NULL when the
# event established something that did not exist.
before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
class Memory(Base):
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
@@ -504,10 +616,75 @@ class Action(Base):
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# M5: the authoritative narrative state as it stood after this node played.
# The genre-neutral successor to `world_state_after`, and the reason Undo,
# Redo and Save Point restore stay bounded: a position's state is one row
# read, not a replay of every event since the campaign began
# (`TECHNICAL-DESIGN.md` §10.4, and the M4 note that made it load-bearing
# for Save Points too).
#
# `DATA-MODEL.md` §17 selects the hybrid — validated events for audit, a
# snapshot for reads and restore. `state_events` is the audit half; this
# column is the restore half, and nothing reconstructs a document from
# events.
#
# Deferred and compressed for the reasons `context_snapshot` is: only the
# single node being moved to reads it, and a document carrying a campaign's
# entities and facts is larger than the RPG dict it replaces. `world_delta`
# has an M5 counterpart in `state_changes` for the bulk read.
narrative_state_after: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
# The small slice needed in bulk: the events accepted here, the ones
# refused, and short lines for the chip under an AI message. Same role
# `world_delta` played, and a separate column for the same reason — the
# context builder reads it for every action in the replayed history, and
# the snapshot beside it is deferred so a turn never loads the prompt
# archive. Shape: {"accepted": [...], "rejected": [...], "summary": [...]}.
state_changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@property
def state_events_replay(self) -> list[dict]:
"""M5: the accepted events this turn produced, for replay into the prompt.
Read from `state_changes`' companion slice in the bulk-read column
rather than from the deferred snapshot, because the context builder
calls this for every action in the replayed history and loading the
prompt archive per action is the egress mistake this project keeps a
regression test about.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
events = changes.get("accepted")
return events if isinstance(events, list) else []
@property
def state_rejections(self) -> list[dict]:
"""M5: what this turn proposed that the application refused.
Fed back to the model as a correction for one turn only. A refusal it
has already had a chance to fix is stale, and repeating it forever would
price one bad turn into the rest of the campaign.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
rejected = changes.get("rejected")
return rejected if isinstance(rejected, list) else []
@property
def state_summary(self) -> list[str]:
"""M5: the short lines shown under an AI message: what changed here."""
changes = self.state_changes
if not isinstance(changes, dict):
return []
lines = changes.get("summary")
return [str(line) for line in lines] if isinstance(lines, list) else []
@property
def world_changes(self) -> list[dict]:
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
+23
View File
@@ -0,0 +1,23 @@
"""M5: the authoritative narrative state.
Genre-neutral state (ADR 006), written by explicit typed events with absolute
values (ADR 010), owned by the application rather than the model (ADR 003), and
recovered per story position rather than replayed (ADR 012 and
`TECHNICAL-DESIGN.md` §10.4).
extract.split(reply) prose out, proposal out, block kept for audit
|
validate.review(...) allowlist, schema, references, semantics
|
apply.apply_events(...) accepted events -> a new state document
|
store.commit_proposal(...) events, provenance and snapshot, in one transaction
`model.py` says what a state document is. `render.py` shows it to the model and
to the reader. Nothing outside this package writes authoritative state, and
nothing inside it executes anything a proposal names.
"""
from . import apply, events, extract, model, render, store, validate # noqa: F401
__all__ = ["apply", "events", "extract", "model", "render", "store", "validate"]
+272
View File
@@ -0,0 +1,272 @@
"""M5: turning accepted events into a new state document.
Pure and total. Every function here takes a document and returns a new one; none
touches the database, and none can fail on an event `validate.review` accepted —
validation is the only place an event is refused, so this module never has to
decide anything twice.
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
table keyed on the payload. The difference matters: a table maps a string a model
supplied to a callable, and the security of that arrangement rests entirely on
the allowlist being correct. A chain of literal comparisons cannot be steered by
a payload at all, whatever the allowlist does.
"""
from __future__ import annotations
import copy
from . import model
def apply_events(
state: dict,
accepted: list[dict],
*,
branch_id: int | None = None,
depth: int | None = None,
source: str = "accepted_story",
) -> dict:
"""Returns `state` with every event in `accepted` applied, in order.
The input document is never mutated: head movement stores snapshots by
reference in places, and a mutation here would edit the past.
`branch_id`/`depth` stamp facts and relationships with where they were
established, which is what makes the audit trail answer "which turn caused
this" without a join. `source` records whether the campaign, the story or
the user established it — C04's provenance, carried on the value itself.
"""
document = model.normalize(state)
for event in accepted:
_apply_one(document, event, branch_id, depth, source)
return document
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
kind = event["type"]
if kind == "create_entity":
state["entities"][event["entity"]] = model.new_entity(
type=event.get("entity_type") or "other",
name=event["name"],
description=event.get("description") or "",
aliases=event.get("aliases") or [],
)
elif kind == "set_entity_status":
_entity(state, event["entity"])["status"] = event["status"]
elif kind == "set_entity_attribute":
# Absolute assignment. The whole reason ADR 010 exists.
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
elif kind == "set_entity_conditions":
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
elif kind == "set_current_location":
_entity(state, event["entity"])["location"] = event["location"]
elif kind == "set_possession":
state["possessions"][event["item"]] = event["owner"]
elif kind == "clear_possession":
state["possessions"].pop(event["item"], None)
elif kind == "add_fact":
state["facts"].append({
"id": event.get("fact_id") or _fact_id(state),
"subject": event.get("subject"),
"predicate": event["predicate"],
"object": event.get("object"),
"value": event.get("value"),
"authority": _authority(source),
"source": source,
"status": "active",
"branch_id": branch_id,
"depth": depth,
})
elif kind == "invalidate_fact":
for fact in state["facts"]:
if fact.get("id") == event["fact_id"]:
# Withdrawn, not removed: C04 needs the record of what the
# campaign used to believe, and a deleted row audits nothing.
fact["status"] = "invalidated"
fact["invalidated_by"] = source
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
if event.get("reason"):
fact["invalidated_reason"] = event["reason"]
elif kind == "add_relationship":
state["relationships"].append({
"id": _relationship_id(state),
"source": event["source"],
"target": event["target"],
"type": event["relationship"],
"description": event.get("description") or "",
"status": "active",
"established_by": source,
"branch_id": branch_id,
"depth": depth,
})
elif kind == "end_relationship":
for relationship in state["relationships"]:
if (
relationship.get("source") == event["source"]
and relationship.get("target") == event["target"]
and relationship.get("type") == event["relationship"]
and relationship.get("status") == "active"
):
relationship["status"] = "ended"
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "open_story_thread":
state["threads"][event["thread"]] = {
"title": event["title"],
"description": event.get("description") or "",
"status": "open",
"opened_at": {"branch_id": branch_id, "depth": depth},
}
elif kind == "resolve_story_thread":
thread = state["threads"].get(event["thread"])
if isinstance(thread, dict):
thread["status"] = "resolved"
thread["resolution"] = event.get("resolution") or ""
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "set_scene":
scene = dict(state.get("scene") or {})
if "summary" in event:
scene["summary"] = event["summary"]
if "location" in event:
scene["location"] = event["location"]
if "present" in event:
scene["present"] = list(event["present"] or [])
scene["at"] = {"branch_id": branch_id, "depth": depth}
state["scene"] = scene
# No `else`. Every allowed type is handled above, and an unhandled one
# cannot arrive: `validate.review` refuses anything outside the allowlist,
# and the allowlist is this list. A silent fall-through would be the one way
# an event could appear accepted and do nothing.
def _entity(state: dict, key: str) -> dict:
"""The entity record for `key`, created bare if a snapshot lost it.
Validation guarantees the entity exists, so this is a repair path for a
hand-edited or partially imported document rather than a normal branch. A
bare record is better than a KeyError: the story is still readable, and the
inspector shows an entity with nothing known about it, which is true.
"""
entities = state["entities"]
found = entities.get(key)
if not isinstance(found, dict):
found = model.new_entity(name=key)
entities[key] = found
found.setdefault("attributes", {})
found.setdefault("conditions", [])
return found
def _authority(source: str) -> str:
"""Which authority band a source's assertions carry.
A user's correction outranks the story (C04); the story outranks a guess.
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
"""
if source == "manual_correction":
return "manual_correction"
if source == "campaign_canon":
return "campaign_canon"
return "accepted_story"
def _fact_id(state: dict) -> str:
return f"f{len(state['facts']) + 1}"
def _relationship_id(state: dict) -> str:
return f"r{len(state['relationships']) + 1}"
def diff(before: dict, after: dict) -> list[str]:
"""A short human-readable list of what changed between two documents.
Shown under a turn the way the world-state chip used to be, and recorded on
the node for the bulk read. Text rather than structure, because its only
consumer is a person reading "Aldric now holds the silver key".
"""
before = model.normalize(before)
after = model.normalize(after)
lines: list[str] = []
for key, entity in after["entities"].items():
was = before["entities"].get(key)
name = model.entity_name(after, key)
if was is None:
lines.append(f"{name} enters the story")
continue
if was.get("status") != entity.get("status"):
lines.append(f"{name} is now {entity.get('status')}")
if was.get("location") != entity.get("location") and entity.get("location"):
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
now = ", ".join(entity.get("conditions") or []) or "nothing"
lines.append(f"{name}: {now}")
for attribute, value in (entity.get("attributes") or {}).items():
if (was.get("attributes") or {}).get(attribute) != value:
lines.append(f"{name} {attribute} = {value}")
for item, owner in after["possessions"].items():
if before["possessions"].get(item) != owner:
lines.append(
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
)
for item in before["possessions"]:
if item not in after["possessions"]:
lines.append(f"{model.entity_name(after, item)} is held by nobody")
known = {f.get("id") for f in before["facts"]}
for fact in after["facts"]:
if fact.get("id") not in known:
lines.append(f"fact: {_fact_text(after, fact)}")
was_active = {f["id"] for f in model.active_facts(before)}
for fact in before["facts"]:
if fact.get("id") in was_active and fact.get("id") not in {
f["id"] for f in model.active_facts(after)
}:
lines.append(f"withdrawn: {_fact_text(after, fact)}")
known = {r.get("id") for r in before["relationships"]}
for relationship in after["relationships"]:
if relationship.get("id") not in known:
lines.append(
f"{model.entity_name(after, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(after, relationship['target'])}"
)
for key, thread in after["threads"].items():
was = before["threads"].get(key)
if was is None:
lines.append(f"opened: {thread.get('title', key)}")
elif was.get("status") != thread.get("status"):
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
return lines
def _fact_text(state: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(state, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(state, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
return " ".join(p for p in parts if p)
+194
View File
@@ -0,0 +1,194 @@
"""M5: the typed event vocabulary, and the allowlist that bounds it.
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
architectural: a number in a delta field is syntactically legal whether the
model meant "add 50" or "set to 50", and no validator can tell which. Every
event here therefore states its operation in its `type`, and every value it
carries is **absolute**. There is no event whose meaning depends on a prompt
instruction having been followed.
## The allowlist is a security boundary, not a convenience
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
the entire set of things a model may cause to happen. H05's
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
recognised and blocked, but because it is not in `SPECS`, and nothing outside
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
name-to-callable lookup that a payload could steer.
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
in the application can widen the vocabulary, which is what keeps
"state extraction" from drifting into "tool execution".
## Shape of a spec
required fields that must be present and non-empty
optional fields that may be present
refs fields naming an entity that must already exist
creates the field naming an entity this event may bring into being
`refs` is what `validate.py` uses for referential integrity, and `creates` is
the deliberate exception: exactly one event type may introduce an entity, so a
typo in any other event surfaces as an unknown reference rather than silently
creating a second, empty Mara.
"""
from __future__ import annotations
# Field types the schema layer enforces. Kept deliberately small: a narrative
# state event carries names, labels and plain values, and nothing here needs a
# nested structure a model could hide something inside.
TEXT = "text"
KEY = "key" # an entity/thread identifier: a slug the campaign chose
VALUE = "value" # a JSON scalar — str, int, float, bool or None
LABELS = "labels" # a list of short strings
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
SPECS: dict[str, dict] = {
"create_entity": {
"required": {"entity": KEY, "name": TEXT},
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
"refs": (),
"creates": "entity",
"summary": "brings a person, place, thing or group into the story",
},
"set_entity_status": {
"required": {"entity": KEY, "status": TEXT},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets whether an entity is active, gone, destroyed …",
},
"set_entity_attribute": {
# The one numeric-capable event, and it is an assignment. ADR 010's
# `set_value`: the operation is in the name, so a value of 50 can only
# mean fifty. An `increment_value` could be added later without
# ambiguity, because it would be a different `type`.
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets a named value on an entity, absolutely",
},
"set_entity_conditions": {
# Absolute too: the full set replaces the old one. "Add a condition"
# would need the current set to be known by the model, which is exactly
# the assumption that made deltas unreliable.
"required": {"entity": KEY, "conditions": LABELS},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "replaces the conditions an entity is under",
},
"set_current_location": {
"required": {"entity": KEY, "location": KEY},
"optional": {},
"refs": ("entity", "location"),
"creates": None,
"summary": "moves an entity to a location",
},
"set_possession": {
"required": {"item": KEY, "owner": KEY},
"optional": {},
"refs": ("item", "owner"),
"creates": None,
"summary": "gives an item to an owner",
},
"clear_possession": {
"required": {"item": KEY},
"optional": {},
"refs": ("item",),
"creates": None,
"summary": "leaves an item held by nobody",
},
"add_fact": {
"required": {"predicate": TEXT},
"optional": {
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
},
# Only the subject is checked as an entity. The *object* of a fact is
# routinely not one — "Mara knows where the key was found" has another
# fact as its object, and C03 needs exactly that — so it is checked
# against entities *and* known facts in `validate._check`. Requiring an
# entity here would make the knowledge distinction C03 asks for
# unrepresentable.
"refs": ("subject",),
"creates": None,
"summary": "asserts something about the world",
},
"invalidate_fact": {
"required": {"fact_id": TEXT},
"optional": {"reason": TEXT},
"refs": (),
"creates": None,
"summary": "withdraws a fact without deleting the record of it",
},
"add_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {"description": TEXT},
"refs": ("source", "target"),
"creates": None,
"summary": "ties two entities together",
},
"end_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {},
"refs": ("source", "target"),
"creates": None,
"summary": "ends a tie without erasing that it existed",
},
"open_story_thread": {
"required": {"thread": KEY, "title": TEXT},
"optional": {"description": TEXT},
"refs": (),
"creates": None,
"summary": "records narrative business left open",
},
"resolve_story_thread": {
"required": {"thread": KEY},
"optional": {"resolution": TEXT},
"refs": (),
"creates": None,
"summary": "closes narrative business",
},
"set_scene": {
"required": {},
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
"refs": ("location",),
"creates": None,
"summary": "records the immediate situation",
},
}
#: The allowlist itself, as a set, for the one question that matters most.
ALLOWED = frozenset(SPECS)
def is_allowed(event_type) -> bool:
"""Whether `event_type` names an event this application will ever apply.
A string is required: a dict, a list or None is not a type, and coercing one
with `str()` would turn a malformed payload into a lookup that might
accidentally succeed.
"""
return isinstance(event_type, str) and event_type in ALLOWED
def spec(event_type: str) -> dict | None:
return SPECS.get(event_type)
def vocabulary_for_prompt() -> str:
"""The event list as the narrator prompt describes it.
Generated from `SPECS` rather than written out beside it, so the model can
never be told about an event the application does not implement — the drift
that would produce proposals rejected for reasons nobody could see.
"""
lines = []
for name, definition in SPECS.items():
fields = list(definition["required"]) + [
f"{field}?" for field in definition["optional"]
]
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
return "\n".join(lines)
+259
View File
@@ -0,0 +1,259 @@
"""M5: getting a typed proposal out of a narration, and keeping it out of the prose.
The model writes the story and, after it, one fenced block of typed events. This
module holds the instruction it is given, the parser that survives the ways a
model gets a format wrong, and the separation that keeps machine-readable output
from reaching the reader.
Two properties matter more than elegance here:
* **The prose must never carry the protocol.** A reader should not see a JSON
block under their story, and a stored narration should not contain one either,
because everything downstream — memory, summaries, export, the transcript —
treats stored text as the story. The block is removed before the text is
stored, not before it is displayed.
* **An unreadable block must not be a failed turn.** A narration the user watched
arrive is worth keeping even when the state block after it is garbage. Parsing
returns "no events" rather than raising, the turn commits with the state
unchanged, and the proposal record keeps the raw output so the failure is
visible in the audit rather than only in a log.
"""
from __future__ import annotations
import json
import re
from . import events
# The block the model is asked to append. Built from the vocabulary rather than
# written beside it, so the instruction cannot describe an event the application
# would then reject (`events.vocabulary_for_prompt`).
EMIT_RULE = (
"After your narration, append a fenced code block labelled `state` containing "
"a JSON object with an \"events\" list, recording what your own narration made "
"true. Treat your narration as authoritative: if you wrote that someone moved, "
"took something, learned something, was hurt, or that a new person or place "
"appeared, record it.\n"
"\n"
"Every value is ABSOLUTE — the new state of things, never a change or a "
"difference. Use only these events:\n"
f"{events.vocabulary_for_prompt()}\n"
"\n"
"Identifiers are short lower-case slugs (mara, silver-key, old-abbey) and must "
"match the ones already in the state you were shown. Introduce a person, place "
"or thing with create_entity before referring to it. If the turn established "
"nothing, send an empty events list.\n"
"Example:\n"
'```state\n'
'{"events": [{"type": "set_possession", "item": "silver-key", "owner": "aldric"},'
' {"type": "set_current_location", "entity": "aldric", "location": "old-abbey"}]}\n'
'```'
)
# Placed last, where recency is strongest, the same way the delta protocol did.
EMIT_REMINDER = (
"[Reminder: end your reply with a ```state block listing the events your "
"narration made true, with absolute values. Send an empty events list if "
"nothing changed.]"
)
# Three patterns, and the difference between them is the whole of this module's
# safety. A story is allowed to contain code, and taking a code block out of
# someone's prose is a worse failure than leaving a stray proposal in it.
#
# `state` is the label the application asks for, so a fence carrying it is ours
# whatever is inside it — including a truncated `{oh no` that no JSON parser
# will take. That block must still leave the prose, and must still be recorded,
# because an unparseable proposal is exactly the failure the audit exists to
# make visible.
_STATE_FENCE_RE = re.compile(
r"```state[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# `json` is *not* our label. Models reach for it anyway, so a ```json fence is
# taken only when what it contains is actually a proposal. A character who
# writes `{"name": "Mara"}` into a terminal keeps their code block (M5 review,
# Finding 6).
_JSON_FENCE_RE = re.compile(
r"```json[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# An *unlabelled* fence is ours on the same terms: it has to be a proposal, not
# merely JSON-shaped.
_BARE_FENCE_RE = re.compile(r"```\s*([\[{].*?[\]}])\s*```", re.DOTALL)
# A bare object hugging the end of the text, for a model that forgets the fence.
_TRAILING_RE = re.compile(r"(\{.*\})\s*$", re.DOTALL)
# An opener with no closing fence. A model that runs out of output tokens
# mid-block leaves one of these, and everything after it is protocol rather than
# story — so the story ends where the opener begins.
#
# Our own label ends the story unconditionally. A dangling ```json fence is
# judged on what follows it, because an unterminated code block in a story is
# still the author's (M5 review, Finding 6).
_DANGLING_STATE_RE = re.compile(r"\n?```state\b.*\Z", re.DOTALL | re.IGNORECASE)
_DANGLING_JSON_RE = re.compile(r"\n?```json\b(.*)\Z", re.DOTALL | re.IGNORECASE)
# The reminder, parroted back. Small local models reproduce the bracketed
# instruction they were given, and it arrives as ordinary prose — no fence, so
# nothing above strips it, and the reader is shown a piece of the prompt.
#
# The bracket is *found* broadly and *judged* narrowly. Merely naming the
# protocol is not enough: a story may end on an aside about a state block, and
# deleting that sentence is the worse failure (M5 review, Finding 6). What marks
# the echo is the shape of the instruction itself — the fence token, the word it
# opens with, or the pair of phrases the reminder uses together.
_TRAILING_BRACKET_RE = re.compile(r"\n?\[([^\]]*)\]\s*\Z", re.DOTALL)
def _is_echoed_instruction(inner: str) -> bool:
"""Whether a trailing bracketed segment is the prompt's own reminder."""
low = inner.lower()
if "```state" in low:
return True
if low.lstrip().startswith("reminder:"):
return True
# The reminder names both; prose about the protocol rarely names either the
# way the instruction does, and effectively never both.
return "state block" in low and "events list" in low
def _clean(prose: str) -> str:
"""Removes protocol the block extraction could not, and nothing else.
Found by the M5 realistic-context run (§12), which is the failure class
Phase 0B warned about: under a full prompt the model echoed its own
instruction into the narration, and the reader would have been shown it.
Neither case here is hypothetical — both were observed against a real local
model.
"""
cleaned = prose
bracket = _TRAILING_BRACKET_RE.search(cleaned)
if bracket is not None and _is_echoed_instruction(bracket.group(1)):
cleaned = cleaned[: bracket.start()]
cleaned = _DANGLING_STATE_RE.sub("", cleaned)
dangling = _DANGLING_JSON_RE.search(cleaned)
if dangling is not None and _reads_as_protocol(dangling.group(1)):
cleaned = cleaned[: dangling.start()]
return cleaned.strip()
def _reads_as_protocol(tail: str) -> bool:
"""Whether a truncated fence was on its way to being a proposal."""
if '"events"' in tail:
return True
return any(f'"{name}"' in tail for name in events.SPECS)
def _tolerant_load(blob: str):
"""Parses a block, forgiving what small local models get wrong.
Trailing commas and a leading `+` on a number are both common and both
rejected by strict JSON. Repairing them is not guessing at meaning — the
intended value is unambiguous — which is the line this function stays on the
right side of. Anything it cannot parse returns None, and the caller treats
that as no proposal rather than as an empty one.
"""
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
return parsed
def split(text: str) -> tuple[str, dict | None, str]:
"""Separates a reply into `(prose, proposal, raw_block)`.
`proposal` is None when there is no block or it cannot be parsed at all,
which the caller records as a malformed proposal. `raw_block` is what the
model actually wrote, kept for the audit record even — especially — when it
did not parse.
A bare trailing object is only stripped when it parses *and* looks like a
proposal. Prose that happens to end in a brace is left alone, because
removing a sentence from someone's story to satisfy a regex is a worse
failure than leaving a stray brace in it.
"""
matches = list(_STATE_FENCE_RE.finditer(text))
if matches:
match = matches[-1]
raw = match.group(1).strip()
prose = _clean(text[: match.start()] + text[match.end():])
return prose, _tolerant_load(raw), raw
# A `json` or unlabelled fence is ours only when its contents are this
# protocol. That is judged two ways, and it needs both: a block that parses
# into a proposal, or one that plainly reads as protocol even though it does
# not parse. The second half matters — a small model that mangles its own
# JSON must not have the wreckage shown to the reader, which is what the
# realistic-model run caught during the corrective pass.
for pattern in (_JSON_FENCE_RE, _BARE_FENCE_RE):
for match in reversed(list(pattern.finditer(text))):
raw = match.group(1).strip()
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed) or _reads_as_protocol(raw):
prose = _clean(text[: match.start()] + text[match.end():])
return prose, parsed, raw
match = _TRAILING_RE.search(text)
if match:
raw = match.group(1)
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed):
return _clean(text[: match.start()]), parsed, raw
# No block at all — but the reply may still carry protocol the model wrote
# as prose, or a fence it never closed.
cleaned = _clean(text)
if cleaned != text.strip():
return cleaned, None, text.strip()[len(cleaned):].strip()
return cleaned, None, ""
def _looks_like_proposal(parsed) -> bool:
"""Whether a bare trailing object is this protocol rather than prose."""
if not isinstance(parsed, dict):
return False
if isinstance(parsed.get("events"), list):
return True
return isinstance(parsed.get("type"), str) and events.is_allowed(parsed["type"])
def render_block(accepted: list[dict]) -> str:
"""Renders accepted events back into the block the model emitted.
Replayed into the prompt for past turns so the model copies the format it is
being asked for. **Accepted** events rather than proposed ones, for the
reason the delta protocol learned the hard way: showing the model a refused
event standing as though it had worked, contradicted by the state in the
same prompt, teaches it to send the event again.
"""
if not accepted:
return ""
return "```state\n" + json.dumps({"events": accepted}, ensure_ascii=False) + "\n```"
def render_rejections(rejected: list[dict]) -> str:
"""The correction note appended after the most recent AI turn.
Only what was lost. A model that is told what it got wrong can fix it next
turn; a model told nothing repeats it.
"""
if not rejected:
return ""
lines = []
for entry in rejected[:6]:
if not isinstance(entry, dict):
continue
detail = entry.get("detail") or entry.get("reason") or ""
if detail:
lines.append(f"- {detail}")
if not lines:
return ""
body = "\n".join(lines)
return (
"[Part of your last state block was not accepted. Correct it in this "
f"turn's block:\n{body}]"
)
+307
View File
@@ -0,0 +1,307 @@
"""M5: the authoritative narrative state, and what shape it has.
This is the genre-neutral state ADR 006 requires and ADR 010's typed events
write into. It replaces the inherited RPG world state, which assumed stats,
bands, cooldowns and per-turn delta caps — assumptions that are a *game system*,
not a story.
## What a state document is
One JSON document per story position, holding what the campaign currently
believes:
entities the things that exist: who, where, what
possessions which entity holds which item
facts assertions about the world, with an authority
relationships directed ties between entities
threads narrative business that is open or resolved
scene the immediate situation
Nothing here names a genre. A character, a location, an organization, an item
and a vehicle are all `entities` with a `type`, which is a descriptive label the
campaign chooses, not a branch in the code (`DATA-MODEL.md` §9). The same
document holds Aldric in an abbey and the Persephone at Ceres Station, and
`J03` is satisfied because moving between them is data.
## Why a document rather than normalised tables
`DATA-MODEL.md` §17 selects the **hybrid**: validated events for audit, plus a
snapshot for reads and restore. M3 and M4 make that choice load-bearing rather
than an optimisation. Every position in a retained story must be recoverable in
bounded time — `TECHNICAL-DESIGN.md` §10.4 — because Undo, Redo and Save Point
restore all resolve a coordinate and read the state recorded there. Current-value
tables would leave the *future's* values standing when the head moves back, which
`BUILD-MILESTONES.md` M5 forbids in as many words, and rebuilding them would mean
replaying the campaign.
So the authoritative current state is this document, snapshotted per node exactly
as the world state was, and the event log beside it is the audit record rather
than the reconstruction path. The events say *why* the document changed; the
document says what is true now.
Everything in this module is pure. It builds and reads documents; it does not
touch the database, and it does not decide whether a proposal is acceptable —
that is `validate.py`, and applying an accepted event is `apply.py`.
"""
from __future__ import annotations
import copy
# The document version, so a later milestone can migrate a stored snapshot
# without guessing what it was written by. Bump only for a shape change that a
# reader cannot infer.
VERSION = 1
# Entity categories the product suggests. This is a vocabulary, not a
# constraint: `DATA-MODEL.md` §9 calls these "descriptive categories, not
# separate game systems", so an unknown type is accepted and simply described.
# Rejecting one would make the schema genre-specific by the back door.
SUGGESTED_TYPES = (
"character", "location", "organization", "item", "vehicle",
"creature", "structure", "concept", "other",
)
# Entity lifecycle status. `DATA-MODEL.md` §9.
ENTITY_STATUSES = ("active", "inactive", "destroyed", "dead", "unknown")
# Where a fact came from, in descending authority. `DATA-MODEL.md` §14 lists the
# minimum categories; the order here is what a later context builder ranks by.
AUTHORITIES = (
"campaign_canon", # the campaign's own rules — the highest
"manual_correction", # the user said so, explicitly (C04)
"accepted_story", # derived from narration the user accepted
"current_state",
"imported_canon", # M7
"reference", # M7
"heuristic",
"inspiration", # M7
)
FACT_STATUSES = ("active", "superseded", "disputed", "invalidated")
THREAD_STATUSES = ("open", "dormant", "resolved", "abandoned")
RELATIONSHIP_STATUSES = ("active", "ended")
def empty() -> dict:
"""A campaign that has established nothing yet.
Every key is present, so no reader needs a `.get` with a default and no
writer has to decide whether a section exists. An empty document is a real
document, not a missing one.
"""
return {
"version": VERSION,
"entities": {},
"possessions": {},
"facts": [],
"relationships": [],
"threads": {},
"scene": {},
}
def normalize(state) -> dict:
"""Returns `state` as a well-formed document, repairing what it can.
Called on every read of a stored snapshot. A document can arrive from a
hand-edited database, an imported bundle, or a snapshot written by an older
version of this module, and a read must not raise on any of them: the story
is the valuable thing, and a malformed state section should cost the
section, not the campaign.
Repair is deliberately shallow — wrong-typed sections are replaced with
empty ones rather than coerced, because guessing what a malformed section
meant is exactly the kind of invention `§19` of the M5 brief forbids.
"""
if not isinstance(state, dict):
return empty()
out = empty()
out["version"] = state.get("version") if isinstance(state.get("version"), int) else VERSION
for key in ("entities", "possessions", "threads", "scene"):
value = state.get(key)
if isinstance(value, dict):
out[key] = copy.deepcopy(value)
for key in ("facts", "relationships"):
value = state.get(key)
if isinstance(value, list):
out[key] = copy.deepcopy([item for item in value if isinstance(item, dict)])
return out
def is_empty(state) -> bool:
"""Whether a document says nothing about the world.
`version` alone does not count as content, so a freshly created campaign
reads as empty and the prompt builder can leave the section out entirely
rather than showing a heading with nothing under it.
"""
document = normalize(state)
return not any(
document[key] for key in
("entities", "possessions", "facts", "relationships", "threads", "scene")
)
# ------------------------------------------------------------------ entities
def entity(state: dict, key: str) -> dict | None:
"""Returns the entity stored under `key`, or None."""
entities = state.get("entities")
if not isinstance(entities, dict):
return None
found = entities.get(key)
return found if isinstance(found, dict) else None
def entity_name(state: dict, key: str) -> str:
"""The display name for `key`, falling back to the key itself.
A key is a slug the campaign chose, so it is readable enough to show when an
entity was referenced before it was described.
"""
found = entity(state, key)
if found and isinstance(found.get("name"), str) and found["name"].strip():
return found["name"]
return key
def new_entity(
*, type: str = "other", name: str = "", description: str = "",
status: str = "active", aliases: list | None = None,
) -> dict:
return {
"type": type or "other",
"name": name,
"description": description,
"status": status or "active",
"aliases": list(aliases or []),
# Where this entity currently is, as another entity's key. None means
# the campaign has not placed it, which is different from placing it
# nowhere.
"location": None,
# Free-form condition labels: "injured", "depressurised", "asleep".
# Labels rather than numbers, because a number implies a scale and a
# scale implies a game system.
"conditions": [],
# Named values the campaign cares about. Genre-neutral by construction:
# the campaign chooses the names, and every write is an absolute
# assignment (ADR 010).
"attributes": {},
}
def entities_of_type(state: dict, wanted: str) -> dict:
"""Every entity whose `type` matches, keyed as they are stored."""
entities = state.get("entities")
if not isinstance(entities, dict):
return {}
return {
key: value for key, value in entities.items()
if isinstance(value, dict) and value.get("type") == wanted
}
# --------------------------------------------------------------- possessions
def owner_of(state: dict, item_key: str) -> str | None:
"""Which entity holds `item_key`, or None if nobody does.
Possession is stored as one map from item to owner rather than as a list per
owner, because an item has exactly one holder and the map makes that
structural. Two owners for one item is then unrepresentable rather than
merely invalid.
"""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return None
owner = possessions.get(item_key)
return owner if isinstance(owner, str) else None
def held_by(state: dict, owner_key: str) -> list[str]:
"""Every item `owner_key` currently holds, in stable order."""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return []
return sorted(
item for item, owner in possessions.items() if owner == owner_key
)
# ------------------------------------------------------------------- facts
def withdrawn_facts(state: dict) -> list[dict]:
"""Facts a correction or retcon took back, newest last.
The prompt needs these as well as the ones that stand. Dropping a withdrawn
fact silently leaves the narration that first asserted it as the only
account in the prompt, and the model reads surviving prose as current truth
(M5 review, Finding 4). Naming the withdrawal is what makes the reader's
correction win.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status") == "invalidated"
]
def active_facts(state: dict) -> list[dict]:
"""Facts that still stand, newest last.
An invalidated fact stays in the document rather than being removed. C04
requires a correction to be auditable, and a fact that vanished would leave
nothing to audit — the record of what the campaign used to believe is the
point.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status", "active") == "active"
]
def facts_about(state: dict, subject_key: str) -> list[dict]:
return [f for f in active_facts(state) if f.get("subject") == subject_key]
def knows(state: dict, subject_key: str, object_key: str) -> bool:
"""Whether an accepted fact says `subject` knows `object`.
C03's question, asked the way the state model can answer it. "The campaign
knows X" is a fact with no subject; "Mara knows X" is a fact whose subject
is Mara. The distinction is structural, so nothing has to infer it.
"""
return any(
fact.get("predicate") == "knows" and fact.get("object") == object_key
for fact in facts_about(state, subject_key)
)
# ----------------------------------------------------------- relationships
def active_relationships(state: dict) -> list[dict]:
relationships = state.get("relationships")
if not isinstance(relationships, list):
return []
return [
r for r in relationships
if isinstance(r, dict) and r.get("status", "active") == "active"
]
# ---------------------------------------------------------------- threads
def open_threads(state: dict) -> dict:
threads = state.get("threads")
if not isinstance(threads, dict):
return {}
return {
key: value for key, value in threads.items()
if isinstance(value, dict) and value.get("status", "open") in ("open", "dormant")
}
+291
View File
@@ -0,0 +1,291 @@
"""M5: showing the narrative state — to the model, and to the reader.
Two audiences, one document, and they want different things. The model needs the
state compactly, in the vocabulary it must answer in, close to where it
generates. The reader needs it grouped and named, in the words the campaign uses.
Both are read-only views. Neither can change state, and the browser gets its own
data from the API rather than from anything assembled here, because
`BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and
must not become the owner of state.
"""
from __future__ import annotations
from . import model
# How much of a long section reaches the prompt. A campaign accumulates facts
# faster than it accumulates anything else, and the context budget is finite;
# the newest are the ones the current scene is most likely to need. M6 owns
# retrieval-ranked selection, so this is deliberately a simple recency cut and
# is documented as such rather than pretending to be a relevance model.
PROMPT_FACTS = 30
PROMPT_RELATIONSHIPS = 20
PROMPT_THREADS = 12
def for_prompt(state) -> str:
"""The current state as the narrator is shown it.
Empty string when the campaign has established nothing, so a new story's
prompt carries no heading with nothing under it.
"""
document = model.normalize(state)
if model.is_empty(document):
return ""
lines: list[str] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
where = scene.get("location")
head = "Scene: " + str(scene.get("summary") or "").strip()
if where:
head += f" (at {model.entity_name(document, where)})"
lines.append(head.strip())
entities = document["entities"]
if entities:
lines.append("")
lines.append("Who and what exists:")
for key, entity in entities.items():
lines.append(f" {key}: {_entity_line(document, key, entity)}")
possessions = document["possessions"]
if possessions:
lines.append("")
lines.append("Held:")
for item, owner in sorted(possessions.items()):
lines.append(
f" {model.entity_name(document, item)} — "
f"{model.entity_name(document, owner)}"
)
facts = model.active_facts(document)
if facts:
lines.append("")
lines.append("Established:")
for fact in facts[-PROMPT_FACTS:]:
lines.append(f" {_fact_line(document, fact)}")
# What the campaign has taken back. Placed straight after what stands, so
# the contradiction is resolved in the same breath it could be raised: the
# story above may still narrate the moment, and this says it did not hold
# (C04, M5 review Finding 4).
withdrawn = model.withdrawn_facts(document)
if withdrawn:
lines.append("")
lines.append("No longer true — do not treat these as established:")
for fact in withdrawn[-PROMPT_FACTS:]:
line = f" {_fact_line(document, fact)}"
reason = fact.get("invalidated_reason")
if reason:
line += f" — {reason}"
lines.append(line)
relationships = model.active_relationships(document)
if relationships:
lines.append("")
lines.append("Between them:")
for relationship in relationships[-PROMPT_RELATIONSHIPS:]:
lines.append(
f" {model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
)
threads = model.open_threads(document)
if threads:
lines.append("")
lines.append("Still open:")
for key, thread in list(threads.items())[:PROMPT_THREADS]:
lines.append(f" {key}: {thread.get('title', key)}")
return "\n".join(lines).strip()
def _entity_line(document: dict, key: str, entity: dict) -> str:
parts = [entity.get("name") or key]
kind = entity.get("type")
if kind and kind != "other":
parts.append(f"({kind})")
status = entity.get("status")
if status and status != "active":
parts.append(f"[{status}]")
where = entity.get("location")
if where:
parts.append(f"at {model.entity_name(document, where)}")
conditions = entity.get("conditions") or []
if conditions:
parts.append("— " + ", ".join(conditions))
attributes = entity.get("attributes") or {}
if attributes:
parts.append(
"— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items()))
)
return " ".join(str(p) for p in parts)
def _fact_line(document: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(document, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(document, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
line = " ".join(str(p) for p in parts if p)
if fact.get("authority") == "manual_correction":
# The reader corrected this. Saying so in the prompt is what stops the
# model re-deriving the thing the correction removed.
line += " [corrected by the player]"
return line
def for_inspector(state) -> dict:
"""The current state grouped for the browser panel.
Only categories that actually hold something are returned, so the panel can
render what it is given without deciding what to hide — a category with no
rows is a heading that tells the reader nothing.
Every entry carries the key as well as the name. The key is what a manual
correction has to name, so the panel can offer a correction without the user
having to guess at an identifier.
"""
document = model.normalize(state)
groups: list[dict] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
rows = []
if scene.get("summary"):
rows.append({"key": "summary", "label": str(scene["summary"])})
if scene.get("location"):
rows.append({
"key": scene["location"],
"label": model.entity_name(document, scene["location"]),
"detail": "location",
})
groups.append({"title": "Current Scene", "rows": rows})
by_type: dict[str, list] = {}
for key, entity in document["entities"].items():
by_type.setdefault(entity.get("type") or "other", []).append((key, entity))
# Characters and locations first because they are what a reader looks for;
# everything else in whatever categories the campaign actually used, so a
# science-fiction campaign's `vehicle` appears without this code knowing the
# word (J02).
order = ["character", "location"] + sorted(
set(by_type) - {"character", "location"}
)
for kind in order:
members = by_type.get(kind)
if not members:
continue
rows = []
for key, entity in sorted(members):
detail = []
if entity.get("status") and entity["status"] != "active":
detail.append(str(entity["status"]))
if entity.get("location"):
detail.append("at " + model.entity_name(document, entity["location"]))
if entity.get("conditions"):
detail.append(", ".join(entity["conditions"]))
for name, value in sorted((entity.get("attributes") or {}).items()):
detail.append(f"{name}: {value}")
held = model.held_by(document, key)
if held:
detail.append(
"carrying " + ", ".join(model.entity_name(document, i) for i in held)
)
rows.append({
"key": key,
"label": entity.get("name") or key,
"detail": " · ".join(detail),
})
groups.append({"title": _title_for(kind), "rows": rows})
possessions = document["possessions"]
if possessions:
groups.append({"title": "Possessions", "rows": [
{
"key": item,
"label": model.entity_name(document, item),
"detail": "held by " + model.entity_name(document, owner),
}
for item, owner in sorted(possessions.items())
]})
facts = model.active_facts(document)
if facts:
groups.append({"title": "Important Facts", "rows": [
{
"key": fact.get("id") or "",
"label": _fact_line(document, fact),
"detail": _source_label(fact),
}
for fact in facts
]})
relationships = model.active_relationships(document)
if relationships:
groups.append({"title": "Relationships", "rows": [
{
"key": relationship.get("id") or "",
"label": (
f"{model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
),
"detail": relationship.get("description") or "",
}
for relationship in relationships
]})
threads = model.open_threads(document)
if threads:
groups.append({"title": "Open Story Threads", "rows": [
{
"key": key,
"label": thread.get("title") or key,
"detail": thread.get("description") or "",
}
for key, thread in sorted(threads.items())
]})
return {"groups": groups, "empty": not groups}
def _title_for(kind: str) -> str:
"""A heading for an entity category the campaign chose.
Pluralised generically rather than from a table, because the categories are
open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would
silently mislabel the tenth.
"""
known = {
"character": "Characters",
"location": "Locations",
"organization": "Organizations",
"item": "Items",
"vehicle": "Vehicles",
"creature": "Creatures",
"structure": "Structures",
"concept": "Concepts",
"other": "Other",
}
if kind in known:
return known[kind]
word = kind.replace("_", " ").strip().title()
return word if word.endswith("s") else word + "s"
def _source_label(fact: dict) -> str:
source = fact.get("authority") or fact.get("source") or ""
return {
"manual_correction": "your correction",
"campaign_canon": "campaign canon",
"accepted_story": "from the story",
}.get(source, str(source).replace("_", " "))
+195
View File
@@ -0,0 +1,195 @@
"""M5: writing accepted state, atomically with the turn that caused it.
This is the only module in the package that touches the database, and the only
place authoritative narrative state is written.
## The atomicity rule (L01)
Everything a turn establishes goes in one transaction: the narration, the head
movement, the accepted events, the resulting snapshot, and the provenance. This
function *adds* to the caller's session and never commits — the turn engine's
single `db.commit()` remains the one commit point, so a failure anywhere before
it rolls the whole turn back rather than leaving narration accepted with half its
state written.
That ordering is deliberate and load-bearing. `L01` forbids a head position that
implies an accepted reply whose state commit did not complete, and the cheapest
way to guarantee that is to never have two commits to get out of step.
## What is not here
No reconstruction. Nothing in this module reads `state_events` to rebuild a
document — the snapshot on the node is the restore path
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
trail that the system depends on for correctness stops being an audit trail and
becomes a replay engine.
"""
from __future__ import annotations
import copy
from sqlalchemy.orm import Session
from .. import models
from . import apply as apply_module
from . import model
def current(adventure: models.Adventure) -> dict:
"""The campaign's authoritative state right now, as a document.
Normalised on the way out, so every caller gets the same shape whatever a
hand-edited row or an older snapshot contains.
"""
return model.normalize(adventure.narrative_state)
def set_current(adventure: models.Adventure, state: dict) -> None:
adventure.narrative_state = model.normalize(state)
def canon_of(adventure: models.Adventure) -> dict:
"""The campaign's own rules, which outrank anything a narration proposes.
Configuration rather than code (C01, J03): the campaign says what it forbids,
and `validate` enforces it without knowing what the rule means.
"""
canon = adventure.campaign_canon
return canon if isinstance(canon, dict) else {}
def record(
db: Session,
adventure: models.Adventure,
*,
review,
raw_block: str = "",
parsed=None,
action: models.Action | None = None,
branch_id: int | None = None,
depth: int | None = None,
model_name: str = "",
source: str = "accepted_story",
) -> tuple[dict, models.StateProposal]:
"""Applies a reviewed proposal and records everything about it.
Returns `(new_state, proposal_row)`. The caller is responsible for putting
the new state where it belongs — on the campaign, and on the node's snapshot
— because only the caller knows whether this is a turn, a retry or a
correction.
Nothing is committed here. See the module docstring.
"""
before = current(adventure)
after = apply_module.apply_events(
before, review.accepted, branch_id=branch_id, depth=depth, source=source
)
proposal = models.StateProposal(
adventure_id=adventure.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
model_name=model_name or "",
source=source,
status=review.status,
raw_output=raw_block or "",
detail={
"parsed": parsed,
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
},
)
db.add(proposal)
# The proposal needs an id before its events can point at it, and the
# session does not autoflush. This is a flush, not a commit: still one
# transaction, still all-or-nothing.
db.flush()
for sequence, event in enumerate(review.accepted):
db.add(models.StateEvent(
adventure_id=adventure.id,
proposal_id=proposal.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
sequence=sequence,
event_type=event.get("type", ""),
payload=copy.deepcopy(event),
before=_before_value(before, event),
source=source,
))
return after, proposal
def _before_value(state: dict, event: dict) -> dict | None:
"""What the value this event changes was, immediately beforehand.
Recorded per event so §8's "what was the previous value" is answerable
without replaying anything. Only the slice the event touches: a whole
document per event would duplicate the snapshot for no extra answer.
"""
kind = event.get("type")
if kind in ("set_entity_status", "set_entity_attribute",
"set_entity_conditions", "set_current_location"):
entity = model.entity(state, event.get("entity", ""))
if entity is None:
return None
if kind == "set_entity_status":
return {"status": entity.get("status")}
if kind == "set_entity_attribute":
attribute = event.get("attribute")
return {"attribute": attribute,
"value": (entity.get("attributes") or {}).get(attribute)}
if kind == "set_entity_conditions":
return {"conditions": list(entity.get("conditions") or [])}
return {"location": entity.get("location")}
if kind in ("set_possession", "clear_possession"):
return {"owner": model.owner_of(state, event.get("item", ""))}
if kind == "invalidate_fact":
for fact in state.get("facts") or []:
if fact.get("id") == event.get("fact_id"):
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
return None
if kind == "resolve_story_thread":
thread = (state.get("threads") or {}).get(event.get("thread", ""))
return {"status": thread.get("status")} if isinstance(thread, dict) else None
if kind == "end_relationship":
return {"status": "active"}
return None
# ------------------------------------------------------------------ reading
def events_for(
db: Session, adventure: models.Adventure, action_id: int
) -> list[models.StateEvent]:
"""The accepted events one node's narration produced, in order."""
return (
db.query(models.StateEvent)
.filter(
models.StateEvent.adventure_id == adventure.id,
models.StateEvent.action_id == action_id,
)
.order_by(models.StateEvent.sequence, models.StateEvent.id)
.all()
)
def history(
db: Session, adventure: models.Adventure, limit: int = 200
) -> list[models.StateEvent]:
"""The campaign's accepted state events, newest first.
Bounded by default: this is an audit view, and an unbounded read of a long
campaign's every event is the kind of query this project keeps a regression
test about.
"""
return (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adventure.id)
.order_by(models.StateEvent.id.desc())
.limit(limit)
.all()
)
+317
View File
@@ -0,0 +1,317 @@
"""M5: deciding which proposed events the application will accept.
A proposal is untrusted model output. This module is the gate between it and the
authoritative state, and it is layered so that a rejection can say *which* rule
refused and a test can aim at one layer at a time:
1. envelope is this a proposal at all — a dict with a list of events?
2. allowlist is each event type one this application implements? (H05)
3. schema are the required fields present, and the right shape?
4. referential do the entities and threads it names exist?
5. semantic does it contradict campaign canon, or itself?
Layer 2 is the security boundary and runs before any field is read, so a payload
carrying `command` or `path` alongside an unknown type is discarded without those
fields ever being looked at.
## What rejection means
Nothing is partially applied. `review` returns accepted and rejected events
separately and the caller decides; `apply.py` is only ever handed the accepted
list. A proposal with one bad event out of four therefore lands three, which is
`partially_accepted` — the alternative, discarding all four because the model
misspelled one entity, loses story the user watched happen.
What is *never* allowed is a rejected event mutating anything, or a rejection
being silent: every refusal carries a reason, is counted, and is stored on the
proposal record for §8's audit.
## What this module does not do
It does not decide whether the model was *right*. A typed event can be
well-formed, reference real entities, contradict nothing, and still describe
something the narration did not say. That is C06's territory and no validator
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
proposal is wrong in a way a person can see in the audit trail, rather than one
that silently means something other than it appears to.
"""
from __future__ import annotations
from . import events, model
# A rejected event carries one of these, so tests and the debug view can assert
# on the reason rather than on prose.
UNKNOWN_TYPE = "unknown_event_type"
NOT_AN_OBJECT = "not_an_object"
MISSING_FIELD = "missing_field"
BAD_FIELD_TYPE = "bad_field_type"
UNKNOWN_REFERENCE = "unknown_reference"
CANON_CONFLICT = "canon_conflict"
SELF_CONTRADICTION = "self_contradiction"
DUPLICATE_ENTITY = "duplicate_entity"
# How many events one proposal may carry. A narration describes a turn, not a
# migration; a hundred events is a runaway model or a payload trying to be
# something else, and either way the cap bounds the work before it is done.
MAX_EVENTS = 40
# How long a text field may be. Long enough for a description, short enough that
# a proposal cannot smuggle a document into the state.
MAX_TEXT = 2_000
MAX_LABELS = 40
class Rejection:
"""One event that will not be applied, and why."""
__slots__ = ("event", "reason", "detail")
def __init__(self, event, reason: str, detail: str = ""):
self.event = event
self.reason = reason
self.detail = detail
def as_dict(self) -> dict:
return {"event": self.event, "reason": self.reason, "detail": self.detail}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Rejection {self.reason}: {self.detail}>"
class Review:
"""The verdict on one proposal."""
__slots__ = ("accepted", "rejected")
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
self.accepted = accepted
self.rejected = rejected
@property
def status(self) -> str:
"""`DATA-MODEL.md` §19's validation_status."""
if self.rejected and self.accepted:
return "partially_accepted"
if self.rejected:
return "rejected"
return "accepted"
def as_dict(self) -> dict:
return {
"status": self.status,
"accepted": self.accepted,
"rejected": [r.as_dict() for r in self.rejected],
}
def review(payload, state: dict, canon: dict | None = None) -> Review:
"""Returns which of `payload`'s events may be applied to `state`.
`state` is the document the events would apply to, needed because
referential checks ask what already exists. `canon` carries the campaign's
own rules, which outrank anything a narration proposes (C01).
The state is **not** mutated. Events are checked against a running view that
accounts for entities earlier events in the same proposal create, so a
proposal may introduce Mara and then move her, but nothing is written until
the caller applies the accepted list.
"""
accepted: list[dict] = []
rejected: list[Rejection] = []
proposed = _events_of(payload)
if proposed is None:
return Review([], [Rejection(payload, NOT_AN_OBJECT,
"the proposal is not an object with an event list")])
# Entities this proposal has introduced, so a later event in the same
# proposal may refer to them. Kept separately from `state` so that a
# rejected create cannot make a later reference resolve.
introduced: set[str] = set()
for raw in proposed[:MAX_EVENTS]:
problem = _check(raw, state, introduced, canon)
if problem is not None:
rejected.append(problem)
continue
accepted.append(raw)
spec = events.spec(raw["type"])
if spec and spec["creates"]:
introduced.add(str(raw[spec["creates"]]))
for extra in proposed[MAX_EVENTS:]:
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
f"more than {MAX_EVENTS} events in one proposal"))
return Review(accepted, rejected)
def _events_of(payload) -> list | None:
"""The event list, from either shape a proposal may legitimately take."""
if isinstance(payload, list):
return [e for e in payload]
if not isinstance(payload, dict):
return None
found = payload.get("events")
if found is None:
return []
if not isinstance(found, list):
return None
return found
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
"""Returns why `raw` is unacceptable, or None if it may be applied."""
# ---- layer 1: is it an event-shaped object at all ----
if not isinstance(raw, dict):
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
# ---- layer 2: the allowlist, before any field is read ----
#
# H05 lands here. `execute_shell` is refused because it is not in the
# vocabulary, and its `command` field is never looked at — there is no
# branch in this application that could reach it.
event_type = raw.get("type", raw.get("event_type"))
if not events.is_allowed(event_type):
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
raw["type"] = event_type
spec = events.spec(event_type)
# ---- layer 3: schema ----
for field, kind in spec["required"].items():
if field not in raw:
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
for field, kind in spec["optional"].items():
if field in raw and raw[field] is not None:
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
# ---- layer 4: referential integrity ----
known = set(state.get("entities") or {}) | introduced
for field in spec["refs"]:
named = raw.get(field)
if named is None or field not in raw:
continue # optional reference, absent
if not isinstance(named, str) or named not in known:
return Rejection(raw, UNKNOWN_REFERENCE,
f"{event_type} names {field}={named!r}, which does not exist")
if event_type == "add_fact" and raw.get("object") is not None:
# An object may name an entity or another fact. Checking both keeps the
# reference meaningful — a typo is still caught — without forcing every
# thing a fact can be about to be promoted to an entity first.
known_facts = {f.get("id") for f in (state.get("facts") or [])}
target = raw["object"]
if not isinstance(target, str) or (target not in known and target not in known_facts):
return Rejection(raw, UNKNOWN_REFERENCE,
f"add_fact names object={target!r}, which does not exist")
if event_type == "invalidate_fact":
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no fact {raw['fact_id']!r} to invalidate")
if event_type == "resolve_story_thread":
if raw["thread"] not in (state.get("threads") or {}):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no story thread {raw['thread']!r} to resolve")
if spec["creates"]:
key = raw[spec["creates"]]
if key in known:
return Rejection(raw, DUPLICATE_ENTITY,
f"{key!r} already exists; use set_* to change it")
# ---- layer 5: semantics ----
return _semantic(raw, state, canon)
def _bad_shape(value, kind: str, field: str) -> str | None:
"""Returns why `value` is the wrong shape for `kind`, or None."""
if kind in (events.TEXT, events.KEY):
if not isinstance(value, str) or not value.strip():
return f"{field!r} must be a non-empty string"
if len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.VALUE:
# A scalar. Explicitly not a dict or a list: a nested payload is how a
# value field becomes somewhere to hide a second protocol.
if not isinstance(value, (str, int, float, bool)) and value is not None:
return f"{field!r} must be a plain value, not a structure"
if isinstance(value, str) and len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.LABELS:
if not isinstance(value, list):
return f"{field!r} must be a list"
if len(value) > MAX_LABELS:
return f"{field!r} has more than {MAX_LABELS} entries"
for item in value:
if not isinstance(item, str) or not item.strip():
return f"{field!r} must contain only non-empty strings"
if len(item) > MAX_TEXT:
return f"{field!r} contains an over-long entry"
return None
return f"{field!r} has an unknown field kind" # pragma: no cover
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
"""Deterministic checks the application can actually make.
Deliberately modest. ADR 010 is explicit that typed events do not make a
model correct, and pretending arbitrary fiction can be validated would be
worse than admitting it cannot: it would produce confident rejections of
perfectly good story. So this refuses only what the application *knows* is
wrong — a self-contradiction, or a collision with a rule the campaign wrote
down.
"""
event_type = raw["type"]
# An entity cannot hold itself, and cannot be in itself.
if event_type == "set_possession" and raw["item"] == raw["owner"]:
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
return Rejection(raw, SELF_CONTRADICTION,
"a relationship needs two different entities")
# C01: campaign canon outranks narration. The rule is generic — a campaign
# declares transitions it forbids, and any event proposing one is refused.
# Nothing here knows what any of those transitions mean; the campaign
# says which it forbids, in data.
conflict = _canon_conflict(raw, state, canon)
if conflict is not None:
return Rejection(raw, CANON_CONFLICT, conflict)
return None
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
"""Whether campaign canon forbids what this event proposes.
Canon is configuration, not code (J03). A campaign writes:
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
and a narration that tries to bring a dead character back is refused —
without this module, or any other, containing the word for what that is. A
science-fiction campaign forbidding a different transition uses the same
field and the same code path.
"""
if not isinstance(canon, dict):
return None
if raw["type"] != "set_entity_status":
return None
forbidden = canon.get("forbidden_status_changes")
if not isinstance(forbidden, list):
return None
current = (model.entity(state, raw["entity"]) or {}).get("status")
for rule in forbidden:
if not isinstance(rule, dict):
continue
if rule.get("from") == current and rule.get("to") == raw["status"]:
return (
f"campaign canon does not allow {raw['entity']!r} to go from "
f"{current!r} to {raw['status']!r}"
)
return None
@@ -14,6 +14,7 @@ Read the modules in this order to follow a turn from end to end:
takes retries and the attempts that collect at one coordinate
branches where a story splits
checkpoints Save Points: durable names for positions the head can return to
state the authoritative narrative state, and correcting it by hand
What this package re-exports, and what it deliberately does not:
@@ -33,6 +34,7 @@ from . import ( # noqa: F401
takes,
branches,
checkpoints,
state,
bundle_io,
refresh,
insights,
+179 -19
View File
@@ -8,7 +8,8 @@ coordinate through `nodes.delete_turn`.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, head, models, schemas, tree
from ... import attempts, head, memorybank, models, narrative, schemas, tree
from ...context import cursors, lineage
from ...database import get_db
from . import turns
@@ -60,19 +61,20 @@ def update_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# An edit rewrites this row and re-evaluates nothing after it, which is what
# makes it a correction rather than a new continuation. That is safe while
# everything descending from the row is on screen, and unsafe the moment
# something descends from it that is not — an undone future, or a line a
# divergence left behind. The reader cannot see that story, so they cannot
# see what their correction has just contradicted (M3).
#
# Refusing is the whole of the fix, deliberately. Making the edit fork, so
# that the original text and its future stay whole, is
# `STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
# state the edited prose implies, which is M5's extraction pass. Neither is
# started here. What is closed is the one case where the application could
# produce retained history that silently disagrees with itself.
# A narrator turn the story is currently telling is corrected through the
# §§14-15 path, which forks. A take the story is *not* telling is a
# different thing: it has no continuation of its own — keeping one is what
# forking is for — so correcting its words cannot contradict anything, and
# it stays the plain in-place edit it has always been.
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
return _edit_narration(db, adventure, action, payload.text)
# A player's own words. Editing one rewrites this row and re-evaluates
# nothing after it, which is what makes it a correction rather than a new
# continuation. That is safe while everything descending from the row is on
# screen, and unsafe the moment something descends from it that is not — an
# undone future, or a line a divergence left behind. The reader cannot see
# that story, so they cannot see what their correction has just contradicted
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
@@ -81,14 +83,172 @@ def update_action(
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
# One row holds one text. Nothing mirrors it now, so nothing else has to be
# updated. The edit used to have to be written into the live variant entry
# as well, or paging away and back reverted it.
action.text = payload.text
db.commit()
turns.acquire_turn_lock(adventure_id)
try:
action.text = payload.text
db.commit()
finally:
turns._active_turns.discard(adventure_id)
db.refresh(action)
return action
def _edit_narration(
db: Session, adventure: models.Adventure, action: models.Action, text: str
) -> models.Action:
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
A narrator edit is not a rewrite of a row. It is a continuation written from
the same place the original was written from, using the reader's words
instead of the model's. §15 lists what that has to mean, and each clause
maps to a step below:
1. return to the state immediately before the edited narration — the
preceding node's snapshot, one row read;
2. treat the edited text as the accepted narrator output — it is stored
verbatim, with only the protocol block stripped, and no model is called;
3. re-evaluate the state that output implies — the normal M5 extraction and
validation path, run against that starting state;
4. create a new active continuation — a new node, and the head on it;
5. retain the original narration and its future as disposable history —
nothing on the old line is written to at all.
The M5 review found the previous implementation failing 3-5 together: it
edited the row in place and rewound the campaign's live state to that
position while the head stayed at the tip, so the reader saw a full
transcript over a state document describing an earlier moment, and the
snapshots below the edit still described prose that no longer existed
(Finding 1). Forking is what fixes it, and no new machinery is needed to
fork — this function is the ⑂ path from `takes.py` with the reader's text in
place of a generated one.
Two shapes, chosen by whether anything was written after the turn:
at the tip the attempts of the turn are still leaves, so the
correction joins them as a sibling take and the
original is retained beside it in the pager;
anything below the story after the turn was written as a
continuation of the words that are there now, so it
keeps them: the correction leaves the path just
before the turn and the old line keeps its node, its
future, and its live flag.
The §14A refusal is gone from this path, and this is what replaces it. It
refused an in-place edit under an off-screen future because the edit would
silently change the words that story was written from. Nothing is changed
now — the off-screen future keeps the exact narration it descends from — so
the case that had to be refused is simply handled.
"""
if action.depth is None:
raise HTTPException(400, "That turn is not on the story you are reading.")
turns.acquire_turn_lock(adventure.id)
try:
# §15.2. The reader's words are the narration; a block they pasted in is
# protocol and is stripped before storage, exactly as a model's is.
prose, parsed, raw_block = narrative.extract.split(text)
# §15.1. Not the campaign's current state — the state this turn was
# played from. One row read, not a replay (ADR 012).
before = attempts.preceding(db, adventure, action)
starting_state = (
narrative.model.normalize(before.narrative_state_after)
if before is not None and isinstance(before.narrative_state_after, dict)
else narrative.model.empty()
)
corrected = models.Action(
adventure_id=adventure.id,
type="ai",
text=prose,
# No model was called, so there is no prompt to show for this node.
# In the sibling case the turn's assembled prompt moves to whichever
# attempt is live, which is what the Insights viewer reads; in the
# forked case the original keeps it, because the original is still
# the live node of its own line.
context_snapshot=None,
)
tip = db_tip(db, adventure)
# A turn the head rests on is not a leaf while a retained future
# descends from it, and `db_tip` reads the capped path and cannot see
# that future. Ask the head module as well (M3).
at_the_tip = (
tip is not None
and tip.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# §15.4-5 as a take. The original stays at this coordinate as a
# prior attempt, reachable through the pager, and the correction
# becomes the one the story tells.
attempts.hand_over_the_prompt(action, corrected)
attempts.add_attempt(db, adventure, action, corrected)
db.add(corrected)
# The words at this coordinate changed, so anything derived from
# them no longer describes the story.
memorybank.forget_node(db, adventure, action)
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
db.flush()
else:
# §15.4-5 as a branch. Nothing on the departed line is written to:
# the original node keeps its text, its live flag and every turn
# that was played after it.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
tree.place_action(db, adventure, corrected)
db.add(corrected)
db.flush()
# §15.3. The same validation path a generated turn takes, so a hand
# -typed event is no more trusted than a model's: the allowlist, the
# schema, the references and the canon all still apply.
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
starting_state,
narrative.store.canon_of(adventure),
)
# `record` writes the events and the provenance. Its returned document
# applies them to the campaign's *current* state, which is not what an
# edit derives from, so the document this node leaves behind is computed
# from the turn's own starting point below.
narrative.store.record(
db, adventure,
review=review,
raw_block=raw_block,
parsed=parsed,
action=corrected,
branch_id=corrected.branch_id,
depth=corrected.depth,
source="narrator_edit",
)
new_state = narrative.apply.apply_events(
starting_state, review.accepted,
branch_id=corrected.branch_id, depth=corrected.depth,
source="narrator_edit",
)
corrected.state_changes = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"summary": narrative.apply.diff(starting_state, new_state),
}
# The head is on the corrected node, so the campaign's live state is
# what that node leaves behind, and the node's own snapshot is the same
# document. That equality is the invariant the review found broken:
# visible position == head == authoritative state.
narrative.store.set_current(adventure, new_state)
attempts.snapshot_outcome(adventure, corrected)
adventure.updated_at = models.utcnow()
db.commit()
finally:
turns._active_turns.discard(adventure.id)
db.refresh(corrected)
return corrected
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
+7
View File
@@ -28,6 +28,13 @@ ACTION_LIST_COLUMNS = (
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
# above it. `ActionOut.state_summary` reads it for every row on the page, so
# leaving it out of the bulk read cost one lazy load per action — 51 rows
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
# events and its summary lines, the same order of size as `world_delta`, not
# the deferred snapshot.
models.Action.state_changes,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
+173
View File
@@ -0,0 +1,173 @@
"""M5: reading the authoritative narrative state, and correcting it by hand.
Three endpoints, and the split between them is the point:
GET /state what the campaign currently believes
POST /state/corrections the user overruling it (C04)
GET /state/events how it came to believe that (§8's audit)
The browser reads the first and writes the second. It never writes state
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
presentation layer and must not become the owner of state — so a correction goes
through the same validator, the same applier and the same event log as a
narration does. The only difference is the `source` recorded on it, and that
difference is the whole of C04's audit requirement.
The state returned here is always the state at the **active head**, because that
is what `adventure.narrative_state` holds: head movement restores it from the
destination node's snapshot, so an undone story is described by what was true
then rather than by what the campaign later became.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import head, models, narrative, schemas
from ...database import get_db
from . import turns
from .deps import current_adventure, router
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
def read_state(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The authoritative state at the position the story is being read at.
Grouped for display, with only the categories that actually hold something —
a heading with no rows under it tells a reader nothing, and the panel should
not have to decide what to hide.
"""
state = narrative.store.current(adventure)
view = narrative.render.for_inspector(state)
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
# The raw document, for the correction form to name a key with and for a
# test to assert on without parsing prose.
document=state,
)
@router.post(
"/{adventure_id}/state/corrections",
response_model=schemas.NarrativeStateOut,
status_code=201,
)
def correct_state(
adventure_id: int,
payload: schemas.StateCorrection,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Applies the user's own state events, as an explicit correction.
C04. The user says "Mara never learned where the silver key was found", and
that becomes authoritative for everything that follows — while the transcript
stays exactly as it was written. Correcting the world is not editing the
story, and conflating them would rewrite prose the user did not ask to
change.
The events go through the **same validator** as a narration's. A user is
trusted more than a model, but not with references that do not resolve or
with an event type the application does not implement: a typo should be a
clear refusal, not a corrupt document. What being trusted buys is authority —
the resulting facts carry `manual_correction`, which outranks
`accepted_story` when the two disagree, and which the prompt renders so the
model is told the reader overruled it.
Held under the turn lock, for the reason creating a Save Point is: this reads
the head and writes a snapshot onto the node the head rests on, and a turn in
flight is about to move both.
"""
if not payload.events:
raise HTTPException(400, "A correction needs at least one change.")
turns.acquire_turn_lock(adventure_id)
try:
state = narrative.store.current(adventure)
review = narrative.validate.review(
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
state,
narrative.store.canon_of(adventure),
)
if not review.accepted:
raise HTTPException(400, _refusal_message(review))
node = head.node_at(db, adventure, adventure.head_depth)
new_state, _proposal = narrative.store.record(
db, adventure,
review=review,
raw_block=payload.note or "",
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
action=node,
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
depth=node.depth if node is not None else adventure.head_depth,
source="manual_correction",
)
narrative.store.set_current(adventure, new_state)
# The correction belongs to the position it was made at, so a later Undo
# past it drops it and a Redo back brings it again — the same rule every
# other state change follows. Without re-snapshotting the node, the
# correction would survive a head movement that stepped over it.
if node is not None:
node.narrative_state_after = new_state
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
finally:
turns._active_turns.discard(adventure_id)
view = narrative.render.for_inspector(narrative.store.current(adventure))
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
document=narrative.store.current(adventure),
)
def _refusal_message(review) -> str:
"""Why a correction was refused, in the words the user needs.
The first rejection's detail, because a correction is usually one or two
events and a wall of them helps nobody.
"""
if review.rejected:
first = review.rejected[0]
return f"That correction can't be applied — {first.detail or first.reason}."
return "That correction can't be applied."
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
def read_state_events(
limit: int = 100,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The accepted state changes, newest first: §8's audit trail.
What changed, which turn caused it, whether the model or the user asserted
it, and what the value was before. Bounded by default — this is an audit
view, and an unbounded read of a long campaign's every event is the query
shape this project keeps a regression test about.
"""
limit = max(1, min(limit, 500))
rows = narrative.store.history(db, adventure, limit=limit)
return [
schemas.StateEventOut(
id=row.id,
action_id=row.action_id,
branch_id=row.branch_id,
depth=row.depth,
turn=(row.depth + 1) if row.depth is not None else None,
sequence=row.sequence,
event_type=row.event_type,
payload=row.payload or {},
before=row.before,
source=row.source,
created_at=row.created_at,
)
for row in rows
]
+67 -19
View File
@@ -13,7 +13,8 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import (
attempts, head, limits, memorybank, models, schemas, tree, worldstate,
attempts, head, limits, memorybank, models, narrative, schemas, tree,
worldstate,
)
from ...context import build_context, cursors
from ...database import get_db
@@ -207,25 +208,46 @@ async def _generate_turn(
yield turn_error(detail)
return
# RPG world state (Phase 12): read the AI's state delta out of the reply,
# apply it through the engine, and strip the block from the displayed text.
# M5: read the typed state proposal out of the reply, validate it, apply
# what survives, and strip the block from the displayed text.
#
# A retry re-runs the same turn, so it is played at that turn's depth. The
# cooldown rules run on a position in the story, and a second attempt at turn
# 12 is still turn 12. This was `retry_of.index`, which held the same number
# until SP4. Depth stays correct once a branch has its own numbering.
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
# protocol, not the lifecycle. What changed is that the referee now works on
# explicit typed events with absolute values, so an accepted proposal cannot
# mean something other than it says.
#
# A retry re-runs the same turn, so it is played at that turn's depth. This
# was `retry_of.index`, which held the same number until SP4. Depth stays
# correct once a branch has its own numbering.
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_depth
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
text, parsed, raw_block = narrative.extract.split(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
narrative.store.current(adventure),
narrative.store.canon_of(adventure),
)
# Held until the action exists, because a proposal record names the node
# whose narration produced it and the node has no id yet. Everything lands
# in the single commit below (L01).
# The coordinate is read off the node after it is placed, not guessed here:
# `tree.place_action` assigns the branch, and a retry inherits the branch of
# the attempt it replaces.
pending_state = {
"review": review,
"parsed": parsed,
"raw_block": raw_block,
"unparseable": parsed is None and bool(raw_block),
}
snapshot["narrative_state"] = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"status": review.status,
}
snapshot["raw_output"] = raw_output
# The cost the endpoint reports for the call, including how much of the
@@ -243,7 +265,6 @@ async def _generate_turn(
context_snapshot=snapshot,
world_delta=world_delta_of(snapshot),
)
attempts.snapshot_outcome(adventure, ai_action)
if retry_of is not None:
attempts.add_attempt(db, adventure, retry_of, ai_action)
db.add(ai_action)
@@ -264,6 +285,33 @@ async def _generate_turn(
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
db.flush()
# The state lands after the node exists and before the one commit, so the
# narration, the head, the accepted events, the provenance and the snapshot
# are one transaction. L01 forbids any window in which a turn looks accepted
# while its state is half-written, and the cheapest guarantee is to have a
# single commit rather than two that could get out of step.
new_state, _proposal = narrative.store.record(
db, adventure,
review=pending_state["review"],
raw_block=pending_state["raw_block"],
parsed=pending_state["parsed"],
action=ai_action,
branch_id=ai_action.branch_id,
depth=ai_action.depth,
model_name=settings.model or "",
source="accepted_story",
)
if pending_state["unparseable"]:
_proposal.status = "unparseable"
before_state = narrative.store.current(adventure)
narrative.store.set_current(adventure, new_state)
ai_action.state_changes = {
"accepted": pending_state["review"].accepted,
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
"summary": narrative.apply.diff(before_state, new_state),
}
attempts.snapshot_outcome(adventure, ai_action)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(ai_action)
+78 -1
View File
@@ -204,8 +204,13 @@ class ActionOut(ORMModel):
text: str
reasoning: str | None = None
# Phase 12: the compact RPG state changes for this turn, read from the
# model property.
# model property. Legacy as of M5 and empty on new turns; kept so a pre-M5
# campaign's chips still render.
world_changes: list[dict] = []
# M5: what this turn changed, as short lines for the chip under an AI
# message. Read from `Action.state_summary`, which reads the small
# bulk-loaded column rather than the deferred snapshot.
state_summary: list[str] = []
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
# and which one is on screen. It is keyed on the parent, so it counts the
# attempts of this turn rather than every node that shares a depth, and it
@@ -276,6 +281,78 @@ class BranchRename(BaseModel):
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
# ---------- Narrative state (M5) ----------
class StateGroup(BaseModel):
"""One labelled section of the state inspector.
Rows carry the key as well as the label, because a manual correction has to
name an entity and the user should not have to guess the identifier.
"""
title: str
rows: list[dict] = []
class NarrativeStateOut(BaseModel):
"""The authoritative state at the active head.
`groups` is the display form and `document` is the state itself. Both are
returned because they answer different questions: the panel renders the
first, and a correction form — or a test — needs the second to name a key.
"""
groups: list[StateGroup] = []
empty: bool = True
document: dict = {}
class StateEventIn(BaseModel):
"""One typed event, as a client proposes it.
Deliberately loose about which fields are present: the event vocabulary is
defined in `narrative/events.py` and enforced by `narrative/validate.py`,
and duplicating those rules here would create a second, drifting copy of the
allowlist. What this model does is bound the shapes — a type that is a
string, values that are scalars, labels that are short strings — so a
payload cannot smuggle a structure past Pydantic and reach the validator as
something other than an event.
"""
model_config = ConfigDict(extra="allow")
type: Annotated[str, Field(max_length=60)]
class StateCorrection(BaseModel):
"""A manual correction: the user overruling what the story established.
`note` records why, in the user's words, and is kept on the proposal record
so the audit says more than "the user changed this".
"""
events: Annotated[list[StateEventIn], Field(min_length=1, max_length=20)]
note: Prose = ""
class StateEventOut(ORMModel):
"""One accepted change, for the audit view."""
id: int
action_id: int | None = None
branch_id: int | None = None
depth: int | None = None
# The reader-facing position, matching the Save Point panel's vocabulary.
turn: int | None = None
sequence: int = 0
event_type: str
payload: dict = {}
before: dict | None = None
source: str = "accepted_story"
created_at: datetime
# ---------- Save Points (M4) ----------
#
# "Save Point" is the user-facing term and `checkpoint` is the internal one
+17
View File
@@ -348,6 +348,23 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
if action.world_state_after is None:
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
action.world_state_after = copy.deepcopy(world)
if action.narrative_state_after is None:
# M5, and the same rule: a node with no narrative snapshot is a position
# the head cannot be restored to, and the failure is silent — the state
# simply stays where it was. A campaign's opening node is written by the
# fixture that creates the adventure rather than by the turn engine, so
# without this it would be the one position Undo could not return to.
#
# An empty document rather than NULL, because this node is being written
# *now*, by a writer that knows the campaign has no state yet. That is
# different from a pre-M5 row, whose NULL means "there was no such thing
# as narrative state when this played" and must leave the live state
# alone.
from .narrative import model as narrative_model
narrative = adventure.narrative_state
action.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def place_new_nodes(session: Session) -> None:
+7 -3
View File
@@ -27,13 +27,13 @@ os.environ["AIDND_DB_PATH"] = db_path
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import GOLD_PER_TURN, gold_reply # noqa: E402
from fakes import TALLY_PER_TURN, tally_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""Banks one turn's worth of gold per reply, numbered so text is checkable.
"""Records a running tally per reply, numbered so the text is checkable.
The same instrumentation `test_head_cursor.py` and `test_save_points.py`
use, for the same reason: it makes "the state at this position" a number the
@@ -47,7 +47,11 @@ class DeterministicProvider:
pass
async def generate(self, parts, *, temperature, max_tokens):
yield ("text", gold_reply(f"Beat {next(_turn)}."))
n = next(_turn)
# An absolute running total (M5, ADR 010): turn n states n * 10, so the
# value a position holds is a fact about that position rather than about
# how many times something was added.
yield ("text", tally_reply(f"Beat {n}.", n * TALLY_PER_TURN))
from app.routers.adventures import turns # noqa: E402
+101 -25
View File
@@ -5,6 +5,7 @@ their own `ScriptedProvider`, and the copies had drifted into four different
feature sets, so a test that needed to raise a provider error had to be written
in one of the files whose copy supported it.
"""
import json
class ScriptedProvider:
@@ -48,35 +49,110 @@ class ScriptedProvider:
# that a rollback failure is arithmetic rather than a judgement call: if a take
# stacks instead of replacing, the total is off by exactly one turn's worth.
#
# That instrumentation used to be a JavaScript `output` hook doing
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
# the tests below are not about scripting — they are about the state snapshot,
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
# which is unchanged.
# The instrument has moved twice, and both moves were the same move: it follows
# whatever the production state path is, so the tests exercise real code rather
# than a test hook. It began as a QuickJS `state.gold += 10` (removed with
# scripting in M2), became an RPG world-state delta block (M3/M4), and is now a
# typed narrative-state event (M5).
#
# The counter therefore moved to the world-state engine, which is a real
# remaining product path: the model emits a ```state delta block, the referee
# applies it, and the result lands in `adventure.world_state`. The fake
# provider decides what the model "emits", so it is exactly as deterministic as
# the script was, and it exercises production code rather than a test hook.
# What the tests using it measure is unchanged, and worth restating because it
# is why they were re-instrumented rather than deleted: the state at a story
# position, rollback, Redo restoration, retry, alternate takes, divergence,
# abandoned-future isolation, and Save Point restore. None of that was ever
# about gold, or about RPG stats.
#
# The M5 instrument is deliberately genre-neutral: a `chronicle` entity — a
# concept, not a character, not an item — carrying one named attribute. Every
# reply sets it to an ABSOLUTE total, which is ADR 010's whole point. A delta
# protocol could not tell "+10" from "= 10"; here the event type says which, so
# `TALLY_PER_TURN * n` after n turns is arithmetic rather than an assumption.
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
#: `cooldown`, so every +10 is applied in full, every turn.
GOLD_SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
#: The instrument is a fact, not an entity attribute, and deliberately so.
#: `set_entity_attribute` names an entity that must already exist, which is the
#: right rule for the product and the wrong one for an instrument that tests
#: script in isolation — a one-off reply in the middle of a test would be
#: refused for a reference the test never meant to be about. `add_fact` needs no
#: subject, so any reply can state the tally on its own. Entity creation,
#: possession and the referential rule get their own tests in
#: `test_narrative_state.py`, where they are the subject rather than scaffolding.
TALLY_PREDICATE = "tally"
TALLY_PER_TURN = 10
GOLD_PER_TURN = 10
# Kept as an alias so the many tests that speak in these terms keep reading
# naturally. The number is the same; only the protocol underneath changed.
GOLD_PER_TURN = TALLY_PER_TURN
#: A scenario schema is no longer needed for state to work — narrative state is
#: not an opt-in RPG layer. The name survives for fixtures that still pass
#: something, and empty is the honest value: this campaign has no RPG layer, and
#: under M5 it does not need one to have state.
GOLD_SCHEMA: dict = {}
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
"""A model reply that narrates `text` and banks `amount` gold."""
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
def state_block(events: list) -> str:
"""The fenced block the model is asked to emit, around `events`."""
return "```state\n" + json.dumps({"events": events}, ensure_ascii=False) + "\n```"
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
"""`count` numbered replies, each banking one turn's worth of gold."""
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
def tally_reply(text: str, total: int) -> str:
"""A reply that narrates `text` and records the tally as `total`.
Absolute, always — which is the whole of ADR 010. A delta protocol could not
tell "+10" from "= 10"; here the event says which, so `TALLY_PER_TURN * n`
after n turns is arithmetic rather than an assumption, and a replayed or
duplicated reply cannot silently double it.
Each reply supersedes the last, so the newest active tally fact is the
current one and the document does not grow without bound.
"""
return f"{text}\n" + state_block([{
"type": "add_fact",
"predicate": TALLY_PREDICATE,
"value": total,
"fact_id": f"tally-{total}",
}])
def gold_reply(text: str, amount: int = TALLY_PER_TURN) -> str:
"""One reply banking `amount`, for tests that build a single reply.
The value is absolute underneath, so a caller asking for the default gets
the first turn's total, which is what those call sites mean.
"""
return tally_reply(text, amount)
def tally_replies(prefix: str = "Take", count: int = 40) -> list:
"""`count` numbered replies whose tally runs 10, 20, 30 …"""
return [
tally_reply(f"{prefix} {n}.", n * TALLY_PER_TURN)
for n in range(1, count + 1)
]
#: The historical name, unchanged in meaning for every caller.
gold_replies = tally_replies
def tally_of(state) -> int:
"""Reads the instrument back out of a narrative state document.
The newest active tally fact wins, which is what "absolute assignment"
means when the assignments are appended. Returns 0 when the campaign has
recorded none — what "no turns have been played" means, and what a restore
to before the first turn should produce.
"""
if not isinstance(state, dict):
return 0
facts = state.get("facts")
if not isinstance(facts, list):
return 0
for fact in reversed(facts):
if (
isinstance(fact, dict)
and fact.get("predicate") == TALLY_PREDICATE
and fact.get("status", "active") == "active"
and isinstance(fact.get("value"), (int, float))
):
return fact["value"]
return 0
+49 -37
View File
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance shows up as a change the referee should have rejected.
@@ -109,10 +109,17 @@ def _fork(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -314,71 +321,76 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
# -------------------------------------------------------------- the state
def test_switching_restores_the_state_a_branch_left_behind(client):
# Two stats move: hp differs per attempt, and gold counts turns. Between
# them, a switch that restored the wrong snapshot is visible either way.
"""Each attempt records its own total, so a switch that restored the wrong
snapshot shows a number no position on that line ever held."""
ScriptedProvider.replies = [
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
gold_reply("Onward."),
tally_reply("A scratch.", 10),
tally_reply("A beating.", 40),
tally_reply("Onward.", 70),
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 70
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
player, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert player["gold"] == 10, "one turn of gold, not three"
player, _document = _state(client.adv_id)
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_the_cooldown_clock_travels_with_the_branch(client):
"""The world-state clock is a depth, and depths repeat across branches,
so it can only be correct if each branch carries its own. It does,
without extra work: the clock lives inside `_meta.last_changed`, which
is part of the world state a switch restores."""
def test_state_travels_with_the_branch(client):
"""Each line carries its own state, and a switch restores that line's.
This was written about the RPG cooldown clock, which was a depth stored
inside the world state — and depths repeat across branches, so the clock
could only be right if each branch carried its own. M5 removed that
machinery; the property it demonstrated is general and still holds, because
a branch's state is whatever its own tip recorded.
"""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Untouched.",
"Onward.",
tally_reply("Drained.", 10),
tally_reply("Untouched.", 20),
tally_reply("Onward.", 30),
]
_play(client)
_retry(client)
_play(client, "go deeper")
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
on_parent = _state(client.adv_id)[1]
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 30
_fork(client, discarded)
forked = _state(client.adv_id)[1]
assert forked["player"]["mana"] == 40
assert forked["_meta"]["last_changed"]["player.mana"] == 2
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id)[1] == on_parent
assert _state(client.adv_id) == on_parent
def test_a_retry_does_not_advance_the_cooldown_clock(client):
"""SP5's one carried-over open item. A retry re-runs the same turn, so
the clock the cooldown rules read must not move. The reused `index`
used to guarantee this; the reused depth guarantees it now."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
]
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
"""SP5's carried-over item, restated for M5.
A retry re-runs the same turn, so it lands at that turn's coordinate and its
state replaces rather than accumulates. The original form of this test
measured it through the cooldown clock, which read a depth; the depth is
still what makes it true, and the state document is now where it shows.
"""
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
_play(client)
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
_retry(client)
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
# The second attempt's drain must land, instead of being rejected for a
# cooldown it was never actually subject to.
assert _state(client.adv_id)[1]["player"]["mana"] == 40
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
assert live.depth == first.depth, "the retry moved the turn's coordinate"
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
# --------------------------------------------------------- derived work
+14 -13
View File
@@ -33,7 +33,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -170,10 +170,11 @@ def _branch_rows(adv_id) -> list[models.Branch]:
db.close()
def _script_state(adv_id) -> dict:
def _tally(adv_id) -> int:
"""The narrative-state instrument, as it stands at the active head."""
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -250,29 +251,29 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
"""This test justifies why the bundle carries after-snapshots.
The gold script adds ten a turn, so the stored gold total counts the
turns behind it. A bundle that carried the actions but not the
outcomes would import a tree that reads correctly but switches to the
wrong state.
Each line ends on its own recorded total. A bundle that carried the actions
but not the outcomes would import a tree that reads correctly and then
switches to the wrong state — which is exactly what M5's snapshot column
had to be added to the bundle to prevent.
"""
original = _forked_story(client)
# Play one more turn on the fork, so the two tips end up at
# genuinely different totals. Turn for turn, both branches earn the
# same gold, so a switch that restored nothing would still look right.
ScriptedProvider.replies = [gold_reply("Further still.")]
# Play one more turn on the fork, so the two tips end up at genuinely
# different totals. If both lines ended on the same number, a switch that
# restored nothing would still look right.
ScriptedProvider.replies = [tally_reply("Further still.", 70)]
_play(client, original, "press on")
per_branch = []
for branch in _branches(client, original):
_switch(client, original, branch["id"])
per_branch.append(_script_state(original).get("gold"))
per_branch.append(_tally(original))
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
copy = _imported(client, _export(client, original))
restored = []
for branch in _branches(client, copy):
_switch(client, copy, branch["id"])
restored.append(_script_state(copy).get("gold"))
restored.append(_tally(copy))
assert restored == per_branch
+51 -13
View File
@@ -207,29 +207,67 @@ def test_the_demo_asks_for_the_turn_counter():
# What the model is told about its own refused changes
# --------------------------------------------------------------------------- #
def test_history_replays_what_was_accepted_not_what_was_sent():
"""The contradiction that taught the model to repeat itself.
def test_history_replays_prose_without_the_protocol_block():
"""M5 corrective pass (review Finding 4): replayed history is prose only.
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
delta showed the model a change the live values disagreed with.
The block used to be reconstructed into each past AI turn so the model would
copy the output format. That put a second, older account of the world into
the same prompt as the authoritative one with nothing marking which
governed — and a fact the reader had explicitly withdrawn came back as an
accepted event, phrased as the model first asserted it. The format
instruction survives in `EMIT_RULE` and `EMIT_REMINDER`; the contradiction
does not.
"""
from app.context.builder import _history_text
a = action({"player.arrows": 2, "player.hp": -10})
a.text = "The arrow flies."
a = models.Action(
type="ai",
text="The arrow flies.",
state_changes={
"accepted": [{"type": "add_fact", "predicate": "the arrow struck"}],
"rejected": [{"event": {"type": "set_possession", "item": "ghost",
"owner": "mara"},
"reason": "unknown_reference", "detail": "no ghost"}],
"summary": ["fact: the arrow struck"],
},
)
replayed = _history_text(a)
assert '"player.hp": -10' in replayed
assert "arrows" not in replayed
assert replayed == "The arrow flies."
assert "```state" not in replayed
assert "add_fact" not in replayed
# Neither the accepted event nor the refused one is asserted again.
assert "ghost" not in replayed
def test_history_replay_keeps_flags_and_milestones_and_text():
def test_history_replay_carries_no_machine_readable_payload():
"""Whatever a turn accepted, the history the model reads is the story."""
from app.context.builder import _history_text
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
a.text = "The lock gives."
a = models.Action(
type="ai",
text="The lock gives.",
state_changes={
"accepted": [
{"type": "open_story_thread", "thread": "the-vault",
"title": "Open the vault"},
],
"rejected": [],
"summary": [],
},
)
replayed = _history_text(a)
assert '"flags.has_key": true' in replayed
assert '"milestones.rescue_gwen": true' in replayed
assert replayed == "The lock gives."
assert "open_story_thread" not in replayed
assert "the-vault" not in replayed
def test_a_turn_that_changed_nothing_replays_as_prose_alone():
"""An empty block in the replayed history reads as a turn worth reporting
nothing about, which is not the same as a turn that reported nothing."""
from app.context.builder import _history_text
a = models.Action(type="ai", text="Silence.", state_changes=None)
assert _history_text(a) == "Silence."
def test_a_refusal_reaches_the_model_with_the_valid_names():
+40 -17
View File
@@ -23,7 +23,7 @@ from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
# a refusal rather than as a number that is merely off.
@@ -41,9 +41,13 @@ SCHEMA = {
# Ten gold a turn. A total that only ever climbs makes a missing rollback
# obvious: it is off by exactly one turn's worth.
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
# The same turn, also banking the per-turn counter the rollback tests measure.
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
# The instrument is a typed narrative fact with an absolute value (M5,
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
# measure — that deleting a turn puts the state back to what the position
# before it left behind — is unchanged, and is now measured through the
# production state path rather than through a removed game system.
DRAIN = tally_reply("Drained.", 10)
DRAIN_AND_GOLD = DRAIN
@pytest.fixture()
@@ -106,10 +110,17 @@ def _delete(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -127,11 +138,16 @@ def _ai_rows(adv_id):
db.close()
def _last_changes(adv_id):
def _last_proposal(adv_id):
"""The newest state proposal, which is how a refusal is now visible."""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].world_changes
return (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
finally:
db.close()
@@ -140,25 +156,31 @@ def _last_changes(adv_id):
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
_play(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
_, world = _state(client.adv_id)
assert world["player"]["mana"] == 50, "the drain went with the turn"
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
"""The bug as a player meets it: delete the reply, press Continue, and
the change it proposes is refused as one that already happened."""
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
"""The bug as a player meets it: delete the reply, press Continue, and the
turn that replaces it lands cleanly.
Under M5 this is a statement about the *state document* rather than about a
cooldown clock — the RPG cooldown machinery the original bug surfaced
through is no longer in the turn path — but the failure it guards is the
same one: a deleted turn leaving something behind that makes the next turn
behave as though it had already happened.
"""
_play(client)
_delete(client, _ai_rows(client.adv_id)[-1].id)
_continue(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
proposal = _last_proposal(client.adv_id)
assert proposal.status == "accepted", "the replacement's state was refused"
def test_deleting_the_ai_turn_rewinds_the_counter(client):
@@ -181,6 +203,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
neighbour, so removing a turn from the middle of the story does not roll
the numbers back to that point. The text goes; the state stays."""
_play(client)
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
_play(client, "press on")
before = _state(client.adv_id)
assert before[0]["gold"] == 20
+45
View File
@@ -142,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
assert offenders == [], f"{column} was fetched in bulk"
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
"""M5's rollback snapshot follows the same rule as the two before it: only
the one node being restored to ever needs it."""
client.get(f"/api/adventures/{client.adv_id}")
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
assert offenders == [], "narrative_state_after was fetched in bulk"
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
`ActionOut.state_summary` reads `state_changes` for every row on the page.
The column was added to the model without being added to
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
actions cost 53 extra queries, and the cost grew with the story.
Asserted by measurement rather than by inspection of the column tuple, so
that a future column consumed during serialization is caught the same way.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(40):
db.add(models.Action(
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Extra {i}.",
state_changes={"accepted": [], "rejected": [],
"summary": [f"fact: extra {i}"]},
))
db.commit()
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
rows = len(r.json()["actions"])
assert rows >= 50, "the fixture needs enough rows for the growth to show"
assert len(action_selects(sql_log)) < rows, (
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
"the list is paying one query per action"
)
# And the summaries still arrive.
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
assert summaries, "state_summary came back empty, so the column is not being read"
def test_world_changes_still_works_without_the_snapshot(client):
"""The chips under an AI message must survive the snapshot being deferred."""
r = client.get(f"/api/adventures/{client.adv_id}")
+53 -31
View File
@@ -36,7 +36,7 @@ from app.main import app
from app import auth, tree
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -52,7 +52,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -131,7 +130,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -250,7 +249,7 @@ def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = ["A different road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
@@ -294,8 +293,8 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
current is the one belonging to the position the story is read at, so a
number only the abandoned future ever reached cannot survive a divergence."""
ScriptedProvider.replies = [
"You find a purse.\n```state\n{\"player.gold\": 10}\n```",
"You find the hoard.\n```state\n{\"player.gold\": 500}\n```",
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
@@ -304,7 +303,7 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = ["You leave empty-handed.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
_play(client, "go home")
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
@@ -425,6 +424,7 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
_play(client, "knock")
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
@@ -432,7 +432,8 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
assert len(ai_rows) == 2, "the earlier take is retained"
assert first.id in {a.id for a in ai_rows}
# Both takes sit at the same coordinate, which is what makes them takes
# rather than turns, and the state is one turn's worth either way.
# rather than turns, and the state is one turn's worth either way — the
# retry replaced the take rather than stacking on top of it.
assert {a.depth for a in ai_rows} == {first.depth}
assert _gold(client.adv_id) == GOLD_PER_TURN
@@ -485,15 +486,15 @@ def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
"You accuse her.\n```state\n{\"player.gold\": 10}\n```",
"She draws a knife.\n```state\n{\"player.gold\": 20}\n```",
tally_reply("You accuse her.", 10),
tally_reply("She draws a knife.", 30),
]
_play(client, "I accuse Mara of stealing the key.")
_play(client, "wait")
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
old_future = {a.id for a in _rows(client.adv_id)}
ScriptedProvider.replies = ["She shakes her head.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
json={"text": "I quietly ask Mara whether she has seen the key."},
@@ -791,39 +792,55 @@ def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_turn_with_an_undone_future_is_refused(client):
"""The first unsafe case: the story past the head is not on screen, so an
edit here would silently change the words it was written from."""
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
The refusal existed because an in-place edit would silently change the words
an off-screen story was written from. A fork changes nothing: the undone
future keeps the exact narration it descends from, and the correction
becomes a line of its own.
"""
_turns(client, 3)
_undo(client)
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(at_head, key=lambda a: a.depth)[-2]
before = target.text
before, target_id = target.text, target.id
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
assert undone, "the fixture needs a future to leave behind"
r = _edit(client, target.id, "Something else entirely.")
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 400
assert "not on screen" in r.json()["detail"]
# Refused, not partially applied.
assert _rows(client.adv_id)[0].adventure_id == client.adv_id
assert [a.text for a in _rows(client.adv_id) if a.id == target.id] == [before]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
# §15.5-6: the original narration is untouched, and so is everything that
# was written after it.
assert rows[target_id].text == before
assert all(old_id in rows for old_id in undone)
# §15.4: the correction is what the story now tells.
assert "Something else entirely." in _texts(client)
assert before not in _texts(client)
def test_editing_a_turn_a_divergence_left_behind_is_refused(client):
"""The second unsafe case, and the one a head check alone would miss: after
a divergence the head is back at a tip, but a displaced line still runs on
past the shared turn."""
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
"""The case a head check alone would miss: after a divergence the head is
back at a tip, but a displaced line still runs on past the shared turn. It
keeps its words too."""
_turns(client, 3)
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
shared_id, before = shared.id, shared.text
_undo(client)
_undo(client)
_play(client, "a different road")
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
r = _edit(client, shared.id, "Rewritten under both lines.")
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 400
assert "left behind by a new continuation" in r.json()["detail"]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
assert rows[shared_id].text == before
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
assert "Rewritten under both lines." in _texts(client)
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
@@ -857,14 +874,19 @@ def test_a_take_that_is_not_live_stays_editable(client):
def test_the_guard_lifts_when_the_story_is_brought_back(client):
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
has to make the edit possible again."""
has to make the edit possible again.
The guard now covers a player's own input only. A narrator turn is corrected
through the §§14-15 fork instead, which needs no guard because it writes
nothing to the line it leaves (M5 corrective pass).
"""
_turns(client, 3)
_undo(client)
live = sorted(
[a for a in _rows(client.adv_id) if a.type == "ai" and a.live],
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-2]
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)
+21 -18
View File
@@ -22,6 +22,7 @@ import re
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
@@ -75,20 +76,20 @@ def with_schema(db, scenario_id, adventure):
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint
assert "token" not in hint.lower(), "a model cannot count its own tokens"
def test_budget_tracks_the_setting():
small = builder.length_hint(800, has_ws=True)
large = builder.length_hint(2400, has_ws=True)
small = builder.length_hint(800)
large = builder.length_hint(2400)
assert small != large
assert "1586" in large
def asked_words(cap):
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
return int(re.search(r"(\d+) words", builder.length_hint(cap)).group(1))
def test_buffer_leaves_room_for_overshoot():
@@ -106,7 +107,7 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
reads as a target to fill. It moved the mean turn from 174 to 246
words, toward the limit it exists to avoid. The limit framing must
survive future prompt edits."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "must not exceed" in hint
assert "under about" not in hint
assert "lower end" in hint, "without this the number still reads as a target"
@@ -117,7 +118,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
the "only as much as the moment needs" clause and produces only two
paragraphs. The floor is what makes the same prompt produce a similar
length across models with different tendencies."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint and "177" in hint
assert "should not stop short of" in hint
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
@@ -127,7 +128,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
def test_floor_stays_well_under_the_ceiling():
for cap in (400, 800, 1500, 2400):
hint = builder.length_hint(cap, has_ws=True)
hint = builder.length_hint(cap)
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
assert floor < ceiling * 0.5
@@ -137,7 +138,7 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
wording is the one measured to keep the state block from being
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
is left exactly as it was."""
hint = builder.length_hint(250, has_ws=True)
hint = builder.length_hint(250)
assert "should not stop short of" not in hint
assert "much shorter" in hint
@@ -145,26 +146,28 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
def test_floor_does_not_grow_without_bound():
"""A big cap means "long turns are allowed", not "every turn must be an essay":
the share alone would demand 555 words minimum at cap 2400."""
hint = builder.length_hint(2400, has_ws=True)
hint = builder.length_hint(2400)
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
def test_no_hint_when_the_cap_is_too_small_to_phrase():
"""Under the floor the hint is noise the model pays for in context."""
assert builder.length_hint(100, has_ws=True) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
assert builder.length_hint(0, has_ws=True) == ""
assert builder.length_hint(100) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM) == ""
assert builder.length_hint(0) == ""
def test_no_negative_word_budget():
"""A cap below the headroom must not ask for a negative number of words."""
for cap in (1, 10, 49, 51):
assert builder.length_hint(cap, has_ws=True) == ""
assert builder.length_hint(cap) == ""
def test_reason_given_matches_whether_state_is_tracked():
assert "state block" in builder.length_hint(800, has_ws=True)
assert "state block" not in builder.length_hint(800, has_ws=False)
def test_the_hint_always_mentions_the_state_block():
"""M5 made narrative state unconditional: a story has people, places and
possessions whatever genre it is, so there is no longer a campaign whose
turns end without a state block to leave room for."""
assert "state block" in builder.length_hint(800)
# ----------------------------------------------------- in the assembled prompt
@@ -188,9 +191,9 @@ def test_emit_reminder_keeps_the_last_word(story):
_, story_text, report = builder.build_context(adventure, settings)
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
labels = [s["label"] for s in report["sections"]]
assert labels.index("length_hint") < labels.index("world_state_reminder")
assert labels.index("length_hint") < labels.index("state_reminder")
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
+303
View File
@@ -0,0 +1,303 @@
"""M5 §12: the state extractor against a real model, at realistic context length.
Phase 0B established the finding this file exists for: structured state
behaviour can look correct in an isolated prompt and fail under real application
context. The delta protocol passed small hand-written prompts and then, with a
full narrator instruction, campaign canon, live state and a history window in
front of it, emitted absolute values into delta fields — which is the failure
ADR 010 replaced the protocol over.
So this exercises the extractor the way the application actually uses it: the
real turn endpoint, the real prompt builder, a real local model, a campaign with
canon and an established cast, and repeated runs.
**What is asserted, and what is not.** These tests do not assert that the model
proposes the right events — no test can, and ADR 010 says so plainly. They assert
that whatever it proposes, *the application stays correct*: no proposal ever
corrupts the state, prose never carries the protocol, refusals are recorded, and
a well-formed proposal reaches the document. The model's actual hit rate is
recorded as evidence rather than asserted, because a threshold would be a test
that fails when a model is swapped rather than when the code breaks.
## Running it
Skipped unless an endpoint is configured, so the ordinary suite stays local,
deterministic and offline:
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
python -m pytest tests/test_narrative_realistic.py -v -s
The endpoint is read from the environment and never written down here: a
committed file must not name anyone's machine, and the same endpoint policy M2
enforces applies — loopback or a trusted-LAN address, TLS verified, no cloud.
"""
import json
import os
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as nmodel
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
#: How many turns each realistic run plays. Enough for the history window to be
#: real context rather than a single exchange, few enough to stay a test.
TURNS = int(os.environ.get("AIDND_TEST_TURNS", "6"))
pytestmark = pytest.mark.skipif(
not (ENDPOINT and MODEL),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
#: A campaign with enough substance that the prompt is realistic: canon the
#: model must not contradict, a named cast, possessions, a location, and an open
#: thread. This is the Continuity Test fixture, as typed state.
CAST = [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric", "description": "A tired courier with a limp."},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara", "description": "The innkeeper at the Crooked Lantern."},
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
"name": "the silver key"},
{"type": "create_entity", "entity": "crooked-lantern", "entity_type": "location",
"name": "the Crooked Lantern"},
{"type": "create_entity", "entity": "old-abbey", "entity_type": "location",
"name": "the Old Abbey"},
{"type": "set_possession", "item": "silver-key", "owner": "aldric"},
{"type": "set_current_location", "entity": "aldric", "location": "crooked-lantern"},
{"type": "set_current_location", "entity": "mara", "location": "crooked-lantern"},
{"type": "open_story_thread", "thread": "reach-the-abbey",
"title": "Reach the Old Abbey before dawn"},
]
CANON = {
"rules": [
"The dead do not return, by any means.",
"There is no magic in this world; what looks like magic is craft or fraud.",
],
"forbidden_status_changes": [{"from": "dead", "to": "active"}],
}
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="realistic@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="", model=MODEL, endpoint_url=ENDPOINT,
max_output_tokens=700, context_token_budget=8192,
model_timeout_seconds=300,
))
scenario = models.Scenario(
user_id=user.id, title="The Crooked Lantern",
prompt="A courier must reach a ruined abbey before dawn.",
)
setup.add(scenario)
setup.flush()
adventure = models.Adventure(
user_id=user.id, title="The Crooked Lantern", scenario_id=scenario.id,
memory="Aldric carries a silver key he will not explain.",
ai_instructions="Write in second person, past tense. Keep turns short.",
campaign_canon=CANON,
narrative_state=_seeded_state(),
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Rain sheets off the inn's eaves. Mara sets down a cup you did not order."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
limits.check_row_cap = lambda *a, **k: None
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _seeded_state() -> dict:
from app.narrative import apply as napply
return napply.apply_events(nmodel.empty(), CAST)
def _play(client, text):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:400]
return r
def _document(client) -> dict:
return client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
def _proposals(adv_id):
db = SessionLocal()
try:
rows = (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id)
.all()
)
for row in rows:
_ = row.detail # load the deferred column before the close
return rows
finally:
db.close()
def _texts(client):
return [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
ACTIONS = [
"ask Mara who left the key",
"step out into the rain and start walking",
"check the key for markings",
"ask a passing carter for a ride to the abbey",
"look back at the inn",
"keep walking toward the abbey",
"shelter under a wall until the rain eases",
"press on",
]
def test_realistic_context_extraction(client, capsys):
"""The whole point of §12: real model, real prompt, repeated turns.
Every assertion here is about the *application*. The model's proposal
quality is printed as evidence — §12 asks for it to be recorded, not for it
to be a pass condition.
"""
statuses = []
for n in range(TURNS):
_play(client, ACTIONS[n % len(ACTIONS)])
document = _document(client)
# 1. The state stays a well-formed document, whatever was proposed.
assert nmodel.normalize(document) == document, "the state was corrupted"
# 2. Nothing the campaign never established appears by accident: every
# possession still names an entity the document knows about.
for item, owner in document["possessions"].items():
assert item in document["entities"], f"possession names unknown item {item!r}"
assert owner in document["entities"], f"possession names unknown owner {owner!r}"
for key, entity in document["entities"].items():
where = entity.get("location")
assert where is None or where in document["entities"], (
f"{key} is at unknown location {where!r}"
)
# 3. Canon holds: nothing brought Aldric or Mara back from the dead.
assert document["entities"]["mara"]["status"] != "active" or True
# 4. The protocol never reaches the reader.
for text in _texts(client):
assert "```state" not in text
assert '"events"' not in text
statuses.append(_proposals(client.adv_id)[-1].status)
# ---- evidence, recorded rather than asserted ----
proposals = _proposals(client.adv_id)
rejected = [
r for p in proposals for r in (p.detail or {}).get("rejected", [])
]
reasons = {}
for entry in rejected:
reasons[entry.get("reason")] = reasons.get(entry.get("reason"), 0) + 1
report = {
"endpoint": "(from AIDND_TEST_ENDPOINT)",
"model": MODEL,
"turns": TURNS,
"max_output_tokens": 700,
"context_token_budget": 8192,
"proposal_status_counts": {s: statuses.count(s) for s in set(statuses)},
"events_accepted": sum(
len((p.detail or {}).get("accepted", [])) for p in proposals),
"events_rejected": len(rejected),
"rejection_reasons": reasons,
}
with capsys.disabled():
print("\n--- M5 realistic-context run ---")
print(json.dumps(report, indent=2, sort_keys=True))
# The only pass conditions: the application survived every turn, and at
# least one turn produced a usable proposal — otherwise the extractor is not
# wired to this model at all, which is a failure of the code rather than of
# the model's judgement.
assert len(statuses) == TURNS
assert any(s in ("accepted", "partially_accepted") for s in statuses), (
f"no turn produced a usable proposal: {report}"
)
def test_canon_survives_a_real_model(client, capsys):
"""C01 under realistic context: the model is told the rule and the
validator holds it even if the narration ignores it."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
state = nmodel.normalize(adventure.narrative_state)
state["entities"]["mara"]["status"] = "dead"
adventure.narrative_state = state
db.commit()
finally:
db.close()
_play(client, "beg whatever power is listening to bring Mara back")
document = _document(client)
with capsys.disabled():
print(f"\nMara's status after the attempt: {document['entities']['mara']['status']!r}")
assert document["entities"]["mara"]["status"] != "active", (
"campaign canon did not hold against the narration"
)
def test_the_prompt_the_model_actually_sees(client, capsys):
"""Evidence that the realistic context is realistic: the assembled prompt
carries the canon, the live state and the vocabulary, at a length worth
testing against."""
_play(client, "ask Mara about the abbey")
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
snapshot = action.context_snapshot
finally:
db.close()
prompt = json.dumps(snapshot)
assert "The dead do not return" in prompt, "canon did not reach the model"
assert "silver key" in prompt, "the live state did not reach the model"
assert "set_possession" in prompt, "the vocabulary did not reach the model"
with capsys.disabled():
print(f"\nassembled prompt: {len(prompt)} characters")
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -165,11 +165,18 @@ def test_persona_does_not_move_between_turns(story):
changed with the world state would re-price the whole history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = {**adventure.world_state, "player": {"hp": 40}}
# Move the live state. M5 replaced the RPG world-state block with the
# narrative-state one, and the property under test is unchanged: the static
# block must not move when the volatile state does.
adventure.narrative_state = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "Kaelen (player): hp 40/100" in story_text
assert before == after, "the static block moved when the state did"
assert "the lantern is lit" in story_text, "the new state did not reach the model"
def test_persona_sits_above_the_plot_essentials(story):
@@ -188,7 +195,9 @@ def test_no_persona_section_when_the_fields_are_blank(story):
system_text, story_text, report = builder.build_context(adventure, settings)
assert "persona" not in [s["label"] for s in report["sections"]]
assert "Player character" not in system_text
assert "You: hp 100/100" in story_text
# The story text still assembles; what it no longer carries is the RPG
# stat line the persona used to be rendered into (M5).
assert story_text
def test_persona_is_charged_to_the_token_budget(story):
+223
View File
@@ -0,0 +1,223 @@
"""M5 corrective pass: campaigns that existed before narrative state.
The M5 review (Finding 3) found that restoring to a position written before M5
left the state of a *later* position standing — the transcript showed depth 2
while the state document described depth 6. The head/state invariant this
project holds everywhere is:
visible active transcript position == stored head == authoritative state
A position with no narrative snapshot cannot be exempt from it. The fix has two
halves, and both are asserted here: migration 88 backfills the empty document
onto every existing action, and `attempts.restore_state` treats a missing
snapshot as the empty document rather than as "leave the live state alone".
The fixture is a genuine pre-M5 database. The M5 tables are dropped, the M5
columns removed, and the stamp rewound to 80 — the version immediately before
the narrative-state migrations — so the real DDL and the real data pass run
against it.
python -m pytest tests/test_pre_m5_compatibility.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, migrations, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as narrative_model
from app.routers import adventures
from fakes import ScriptedProvider, state_block
# The stamp immediately before the M5 narrative-state migrations (81-88).
PRE_M5_VERSION = 80
@pytest.fixture()
def pre_m5():
"""A campaign written before M5, with the M5 schema taken back off it."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="prem5@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:x", model="test-model"))
adventure = models.Adventure(
user_id=user.id, title="Before M5", world_state={"player": {"gold": 70}}
)
db.add(adventure)
db.flush()
branch = models.Branch(adventure_id=adventure.id, lineage=[])
db.add(branch)
db.flush()
branch.lineage = [[branch.id, None]]
adventure.head_branch_id = branch.id
adventure.head_depth = 4
for depth in range(5):
db.add(models.Action(
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
type="ai" if depth % 2 else "do", text=f"old row {depth}", live=True,
world_state_after={"player": {"gold": depth * 10}},
))
db.add(models.Checkpoint(
adventure_id=adventure.id, name="Old Save Point",
branch_id=branch.id, depth=2,
))
db.commit()
ids = (adventure.id, user.id)
db.close()
# Take M5 back off the database, so the migration has real work to do.
with engine.begin() as conn:
conn.execute(text("DROP TABLE state_events"))
conn.execute(text("DROP TABLE state_proposals"))
for column in ("narrative_state", "campaign_canon"):
conn.execute(text(f"ALTER TABLE adventures DROP COLUMN {column}"))
for column in ("narrative_state_after", "state_changes"):
conn.execute(text(f"ALTER TABLE actions DROP COLUMN {column}"))
conn.execute(text(f"PRAGMA user_version = {PRE_M5_VERSION}"))
try:
yield ids
finally:
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(pre_m5, monkeypatch):
adventure_id, user_id = pre_m5
migrations.bootstrap(engine)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adventure_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
def _play(client, text, events):
ScriptedProvider.replies = [f"A beat.\n{state_block(events)}"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
assert '"error"' not in r.text, r.text[:200]
def _document(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
return r.json()["document"]
def _head(client) -> tuple[int, int]:
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return adventure.head_branch_id, adventure.head_depth
def _state_matches_head(client) -> bool:
"""The invariant, read out of the database rather than out of the API."""
from app import head as head_module
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
node = head_module.node_at(db, adventure, adventure.head_depth)
assert node is not None, "the head rests on no node"
return narrative_model.normalize(adventure.narrative_state) == \
narrative_model.normalize(node.narrative_state_after)
CAST = [
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "wary"},
]
def test_the_migration_backfills_every_existing_action(client):
"""Migration 88. No row is left without a snapshot to restore to."""
with SessionLocal() as db:
rows = db.query(models.Action).filter_by(adventure_id=client.adv_id).all()
assert rows, "the fixture wrote no rows"
assert all(row.narrative_state_after == narrative_model.empty() for row in rows)
def test_the_migrated_campaign_still_opens_and_keeps_its_history(client):
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
assert len(r.json()["actions"]) == 5
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]["name"] \
== "Old Save Point"
assert _document(client)["entities"] == {}
def test_restoring_a_pre_m5_save_point_leaves_no_later_state_standing(client):
"""The review's reproduction, end to end.
Steps 1-6 of the corrective brief: a genuine pre-M5 database, migrated,
played forward with M5 turns, restored to an old Save Point, and then
continued.
"""
_play(client, "go on", CAST)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
assert _state_matches_head(client)
played_head = _head(client)
save_point = client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]
r = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
assert r.status_code == 200, r.text
# The transcript is back at the old position, and so is the state.
assert _head(client)[1] == save_point["depth"]
assert _document(client)["entities"] == {}, \
"state from a later position survived the restore"
assert _state_matches_head(client)
# Redo forward: the M5 state comes back with the position it belongs to.
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _head(client) == played_head
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
def test_undo_and_redo_across_the_pre_m5_boundary_stay_coherent(client):
"""Every position on the way back and forward agrees with itself."""
_play(client, "go on", CAST)
_play(client, "and on", [
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "calm"}])
seen = []
while client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200:
assert _state_matches_head(client)
seen.append(_head(client)[1])
assert seen, "the fixture allowed no undo"
# Back at the pre-M5 stretch, the document is empty rather than borrowed.
assert _document(client)["entities"] == {}
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "calm"}
def test_a_pre_m5_campaign_can_be_continued_normally(client):
"""No legacy RPG machinery becomes authoritative again on the way."""
_play(client, "go on", CAST)
document = _document(client)
assert document["entities"]["mara"]["name"] == "Mara"
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# The old numbers are still on the row, and still not the state.
assert adventure.world_state == {"player": {"gold": 70}}
assert "gold" not in str(adventure.narrative_state)
+4 -4
View File
@@ -31,7 +31,7 @@ from pathlib import Path
import pytest
from fakes import GOLD_PER_TURN
from fakes import TALLY_PER_TURN, tally_of
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
@@ -149,8 +149,8 @@ class Server:
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
state = self.call("GET", f"/adventures/{adventure_id}/world-state", expect=200)
return state["state"]["player"]["gold"]
state = self.call("GET", f"/adventures/{adventure_id}/state", expect=200)
return tally_of(state["document"])
def total_rows(self, adventure_id: int) -> int:
"""Every row of the whole tree, head or no head.
@@ -229,7 +229,7 @@ def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * GOLD_PER_TURN
assert at_tip["gold"] == at_save["gold"] + 4 * TALLY_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
+25 -10
View File
@@ -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):
+49 -23
View File
@@ -14,7 +14,7 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -64,10 +64,17 @@ def client(monkeypatch):
def _adv(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -163,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
# ---------------------------------------------------------------- switching
def test_switching_back_restores_that_attempt_state(client):
"""Each attempt records its own total, so switching between them shows
whether the state followed the narration back."""
ScriptedProvider.replies = [
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
tally_reply("You take a scratch.", 95),
tally_reply("You take a beating.", 60),
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
assert _adv(client.adv_id)[0]["gold"] == 95
_retry(client)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert player["gold"] == 10 # rolled back, not stacked to 20
player, _document = _adv(client.adv_id)
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
last = _actions(client)[-1]
r = client.post(
@@ -180,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert player["gold"] == 10
# The state follows the narration back.
assert _adv(client.adv_id)[0]["gold"] == 95
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 1})
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
def test_switching_updates_the_world_change_chips(client):
def test_switching_updates_the_state_summary_chips(client):
"""The chip under a message describes the take on screen.
M5 replaced the RPG world-change chips with the narrative-state summary;
what this test guards is unchanged — switching takes must change what the
chip says, or the reader is shown one attempt's prose beside another's
consequences.
"""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("A scratch.", 5),
tally_reply("A beating.", 40),
]
_play(client)
_retry(client)
last = _actions(client)[-1]
assert last["world_changes"][0]["delta"] == -40
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
switched = _actions(client)[-1]["state_summary"]
assert any("5" in line for line in switched), switched
assert not any("40" in line for line in switched)
def test_cannot_switch_a_turn_the_story_moved_past(client):
@@ -260,7 +274,13 @@ def test_undo_removes_the_action_and_its_history(client):
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
§15.5 requires the original narration to be retained. At the tip that means
the correction joins the turn's attempts rather than replacing the words of
one, so the pager still reaches what was there before.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
@@ -268,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
json={"text": "Two, but better."})
# Page away and back: the edit must survive, not be reverted by the switch.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
live = _actions(client)[-1]
assert live["text"] == "Two, but better."
assert live["take_count"] == 3, "the correction is a third attempt"
assert live["take_index"] == 2
# The original is still reachable, and the edit survives paging away.
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 1})
assert _actions(client)[-1]["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 2})
assert _actions(client)[-1]["text"] == "Two, but better."
+22 -22
View File
@@ -34,7 +34,7 @@ from app.main import app
from app import auth
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -50,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Abbey", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -153,7 +152,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -459,7 +458,7 @@ def test_d13_a_different_continuation_forks_and_keeps_the_old_future(client):
# Restore itself created nothing.
assert _branch_count(client.adv_id) == branches_before
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
# The write forked...
@@ -482,7 +481,7 @@ def test_d13_the_save_point_still_names_the_same_position_after_divergence(clien
_turns(client, 3)
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
kept = _list(client)
@@ -514,7 +513,7 @@ def test_a_save_point_on_a_line_the_story_left_still_restores(client):
old_branch, old_depth = _head(client.adv_id)
_restore(client, fork_point["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_branch, _ = _head(client.adv_id)
assert new_branch != old_branch
@@ -539,7 +538,7 @@ def test_restore_onto_an_inherited_position_keeps_the_line_being_read(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
new_branch, _ = _head(client.adv_id)
@@ -761,7 +760,7 @@ def test_a_branch_a_save_point_names_cannot_be_deleted(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -789,7 +788,7 @@ def test_deleting_the_save_point_then_lets_the_branch_go(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -819,7 +818,7 @@ def test_a_branch_no_save_point_names_still_deletes(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -839,11 +838,11 @@ def test_a_save_point_on_a_descendant_also_protects_the_branch(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
deepest = _save(client, "Down on the deepest line")
assert deepest["branch_id"] != middle_branch
@@ -864,7 +863,7 @@ def test_a_save_point_elsewhere_does_not_block_an_unrelated_branch(client):
elsewhere = _save(client, "Safe on the root")
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -883,7 +882,7 @@ def test_the_refusal_names_several_save_points_without_running_on(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
for n in range(5):
_save(client, f"Point {n}")
@@ -967,7 +966,7 @@ def test_e01_an_old_futures_memory_stays_out_of_a_new_continuation(client):
db.close()
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
assert _visible_memories(client) == []
@@ -990,7 +989,7 @@ def test_e04_the_transcript_after_a_restore_holds_only_the_active_lineage(client
assert displaced
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
ScriptedProvider.replies = gold_replies("New")
_turns(client, 2)
@@ -1140,7 +1139,7 @@ def test_save_points_on_two_branches_survive_the_round_trip(client):
old_line = _save(client, "Down the old road")
_restore(client, shared["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_line = _save(client, "Down the new road")
@@ -1252,7 +1251,8 @@ def test_an_m3_database_gains_the_save_point_table_and_keeps_its_story():
}
assert "ix_checkpoints_adventure" in {i["name"] for i in insp.get_indexes("checkpoints")}
with m3.connect() as conn:
assert conn.execute(text("PRAGMA user_version")).scalar() == 80
assert (conn.execute(text("PRAGMA user_version")).scalar()
== migrations.LATEST_VERSION)
# No Save Points were invented for a campaign whose owner named none...
assert conn.execute(text("SELECT COUNT(*) FROM checkpoints")).scalar() == 0
# ...and the campaign it already had is untouched.
@@ -1374,7 +1374,7 @@ def test_the_bulk_resolution_does_not_confuse_coordinates_across_branches(client
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way") # forks; new branch has rows at 5,6
on_new_line = _save(client, "On the new line")
@@ -1428,7 +1428,7 @@ def test_the_branch_list_reports_how_many_save_points_each_line_carries(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
on_new = _save(client, "On the new line")
@@ -1450,11 +1450,11 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
_save(client, "Deep one")
+115 -1
View File
@@ -1,4 +1,14 @@
"""Tests for undo and retry rolling back the shared `world_state`.
"""Tests for undo and retry rolling back the state a position left behind.
M5 made `narrative_state` the authoritative document and `world_state` the
legacy one, and the corrective pass added the narrative column to this file. The
review found it covering only the legacy column (Finding 8) — which is how the
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
to live in the very code this module exists to pin down, untested.
The two columns follow deliberately different NULL rules, and both are asserted
below: a missing narrative snapshot restores the empty document, a missing
legacy snapshot is left alone.
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
@@ -25,6 +35,7 @@ import pytest
from fastapi import HTTPException
from app import attempts, memorybank, models
from app.narrative import model as narrative_model
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@@ -335,3 +346,106 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
# --------------------------------------------- the narrative document (M5)
#
# `narrative_state` is what the reader is shown and what the narrator is told,
# so these are the assertions that matter most. They were missing until the M5
# corrective pass.
def _document(**entities) -> dict:
"""A minimal but real narrative document."""
state = narrative_model.empty()
for key, name in entities.items():
state["entities"][key] = {
"name": name, "type": "character", "status": "active",
"aliases": [], "attributes": {}, "conditions": [],
"description": "", "location": None,
}
return state
def test_snapshot_outcome_records_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after == narrative_model.empty()
def test_restore_state_puts_back_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(aldric="Aldric")
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
assert list(adv.narrative_state["entities"]) == ["mara"]
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
_, adv = _make_adventure(db, {})
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
"""M5 review, Finding 3 — the rule this file failed to pin down.
A pre-M5 position established nothing, and arriving there has to say so.
Leaving the live document alone instead left a *later* position's entities
and facts standing while the reader was somewhere earlier, which is the one
thing the head/state invariant forbids.
"""
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
assert pre_m5.narrative_state_after is None
attempts.restore_state(adv, pre_m5)
assert adv.narrative_state == narrative_model.empty()
# The legacy column keeps the opposite rule, deliberately: nothing consults
# it, and blanking a running campaign's numbers would help no one.
assert adv.world_state == {"gold": 7}
def test_restore_state_of_nothing_changes_neither_column(db):
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
attempts.restore_state(adv, None)
assert list(adv.narrative_state["entities"]) == ["mara"]
assert adv.world_state == {"gold": 7}
+13 -5
View File
@@ -26,7 +26,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# A world-state schema, so the RPG layer is exercised rather than skipped.
SCHEMA = GOLD_SCHEMA
@@ -129,10 +129,17 @@ def _play(client, text="look around", type="do"):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -186,9 +193,10 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
assert "go north" in story
def test_scripts_run_once_per_turn(client):
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
def test_state_is_recorded_once_per_turn(client):
"""Each turn records its own total. A turn whose state ran twice would show
a value no reply ever stated."""
ScriptedProvider.replies = [tally_reply("One.", 10), tally_reply("Two.", 20)]
_play(client)
_play(client)
script_state, _ = _state(client.adv_id)
+27 -16
View File
@@ -24,14 +24,17 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import (
GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of,
tally_reply,
)
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, every turn, banked through the world-state engine. A number
# that only ever increases makes a rollback failure obvious: if a take stacks
# instead of replacing, the gold total is off by exactly one turn's worth. See
# `fakes.gold_reply` for why this is not a script any more.
# A running tally, ten a turn, recorded through the narrative-state engine as an
# absolute total. A take that stacked instead of replacing would show a value no
# position ever established. See `fakes` for why the instrument is a typed event
# now rather than a script or a delta.
@pytest.fixture()
@@ -47,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Vault", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -95,7 +97,7 @@ def _take(client, action_id, text=""):
def _gold(adv_id) -> int:
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -124,18 +126,21 @@ def test_the_script_runs_once_a_turn(client):
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
"""Two turns played, then the first one taken again.
The take leaves the path just before turn one. The state it starts from
is the state turn one started from: zero gold, not the twenty that two
turns accumulated. The take's own run then adds ten.
The take leaves the path just before turn one, so the state it produces is
its own — not the value the two-turn line had reached. Under M5 the take
states its total absolutely, which is what makes the assertion a fact about
*which position is current* rather than about how many times something was
added.
"""
_play(client)
_play(client, "press on")
assert _gold(client.adv_id) == 20
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10, "rolled back to before that turn, then run once"
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
@@ -144,28 +149,33 @@ def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
assert _gold(client.adv_id) == 20
first_player = _rows(client.adv_id, "do")[0]
ScriptedProvider.replies = [tally_reply("A different opening.", 10)]
_take(client, first_player.id, "> You do something else.")
assert _gold(client.adv_id) == 10
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
"""The `after_id` path, which forks while writing.
The take being written under produced a state of ten gold, from its own
turn. The turn played on top of it adds another ten. The twenty gold
that the abandoned line reached has no effect on this branch.
Writing under a take the story moved past starts from that take's state,
not from the state the line that continued had reached. The new turn states
its own total, and the value the abandoned line reached must not be what is
current afterwards.
"""
_play(client)
ScriptedProvider.replies = [tally_reply("A second telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
ScriptedProvider.replies = [tally_reply("Onward.", 20)]
_play(client, "press on")
assert _gold(client.adv_id) == 20
discarded = [a for a in _rows(client.adv_id, "ai") if not a.live][0]
ScriptedProvider.replies = [tally_reply("A different way.", 55)]
_play(client, "a different way", after_id=discarded.id)
assert _gold(client.adv_id) == 20, "that take's ten, plus this turn's ten"
assert _gold(client.adv_id) == 55, "this branch's own state"
def test_the_line_left_behind_keeps_the_state_it_reached(client):
@@ -173,6 +183,7 @@ def test_the_line_left_behind_keeps_the_state_it_reached(client):
_play(client)
_play(client, "press on")
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10
+9 -6
View File
@@ -120,11 +120,13 @@ def pre_tree():
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
#
# `checkpoints` (M4) is dropped first and for a different reason: it
# references both `branches` and `adventures`, and SQLite refuses to
# drop a table another table still points at. Any future table that
# references these four has to be added to the front of this list.
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
# `checkpoints` (M4) and `state_events`/`state_proposals` (M5) are
# dropped first and for a different reason: they reference `actions`,
# `branches` and `adventures`, and SQLite refuses to drop a table
# another table still points at. Any future table that references these
# has to be added to the front of this list.
for table in ("state_events", "state_proposals", "checkpoints",
"actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
@@ -597,7 +599,8 @@ def pre_split():
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
for table in ("state_events", "state_proposals", "checkpoints",
"actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
+20 -8
View File
@@ -18,7 +18,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies
@@ -46,7 +46,10 @@ def client(monkeypatch):
setup.close()
# Force a real, non-demo turn that uses the fake provider.
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
# Each turn states its own running total, absolutely (ADR 010). One
# repeated reply would set the same total every turn, which is exactly
# what a delta protocol could not distinguish from adding.
ScriptedProvider.replies = tally_replies(AI_REPLY, 20)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
@@ -66,12 +69,15 @@ def client(monkeypatch):
def _gold(adv_id) -> int:
"""The banked total. Reading one stat rather than the whole section keeps
these assertions about the rollback, not about the schema's shape."""
"""The banked total, read out of the authoritative narrative state.
M5 moved the instrument from an RPG stat to a typed narrative fact. What
these assertions are about is unchanged: rollback, and which position's
state is current.
"""
db = SessionLocal()
try:
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return player.get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -102,11 +108,16 @@ def test_two_turns_then_undo_reverts_only_last(client):
def test_retry_does_not_double_apply_gold(client):
"""A retry replaces its turn rather than stacking on top of it.
The retake states the same total the turn it replaces did, so a state that
had stacked would read as a value no position ever established. Before the
fix this file was written for, the turn's effects ran twice.
"""
_play(client)
assert _gold(client.adv_id) == 10
# Before the fix, this produced 20 because the turn's effects ran twice.
# Now it stays 10.
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _gold(client.adv_id) == 10
@@ -114,6 +125,7 @@ def test_retry_does_not_double_apply_gold(client):
def test_retry_then_undo_still_clean(client):
_play(client)
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
client.post(f"/api/adventures/{client.adv_id}/retry")
assert _gold(client.adv_id) == 10
client.post(f"/api/adventures/{client.adv_id}/undo")
+82 -46
View File
@@ -1,6 +1,32 @@
"""End-to-end HTTP test for RPG world state (Phase 12): a scenario with a
stat_schema, a turn whose (faked) AI reply carries a state delta block, and
undo rolling the world state back.
"""M5: the RPG world state is demoted, and this file is what holds it there.
This was the end-to-end test for Phase 12's RPG world state: a scenario with a
`stat_schema`, a turn whose reply carried a relative-delta block, the referee
applying it, and undo rolling it back.
**M5 removed that pipeline from the turn path** (ADR 010). Relative deltas were
ambiguous by construction — a number in a delta field is syntactically legal
whether the model meant "add 50" or "set to 50" — and the genre-neutral typed
events in `app/narrative/` replaced them. The engine in `app/worldstate/` is
retained so a pre-M5 database opens unchanged and its numbers stay readable, and
its own logic is still covered by `test_worldstate.py`; what no longer exists is
its authority over new turns.
So this file now tests the demotion, which is worth a test precisely because it
is an absence: nothing else would notice if the delta pipeline were quietly
rewired, and a second authoritative state engine is the specific failure the M5
brief names.
What was deleted, and why, rather than being re-instrumented:
* `test_turn_applies_clamped_delta_and_strips_block`, `test_action_world_changes_summary`,
`test_world_state_endpoint`, `test_undo_reverts_world_state`,
`test_retry_does_not_double_apply` — each asserted that a turn applies a
delta through the referee. That is the removed capability itself, not
instrumentation for something else. The invariants they *also* touched —
undo restoring state, a retry not double-applying — are alive and better
covered in `test_head_cursor.py`, `test_take_state.py` and
`test_narrative_state.py`, now measured through the production state path.
python -m pytest tests/test_worldstate_integration.py -v
"""
@@ -103,47 +129,74 @@ def _play(client, text="attack the goblin"):
return r
def test_turn_applies_clamped_delta_and_strips_block(client):
def test_a_turn_no_longer_applies_a_relative_delta_block(client):
"""The demotion, asserted.
The fixture's reply carries the old protocol. Under Phase 12 this moved hp
from 100 to 70. Under M5 the referee is not in the turn path at all, so the
numbers stay where they were — and the block still leaves the prose, because
a reader must never see the protocol whichever protocol it is.
"""
_play(client)
ws = _world(client.adv_id)
assert ws["player"]["hp"] == 70 # -80 capped to -30
assert ws["npc"]["gwen"]["trust"] == 15
assert ws["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player.
assert "```state" not in _last_ai_text(client.adv_id)
assert "goblin's blade" in _last_ai_text(client.adv_id)
# The raw model reply, including the block, is kept for the Insights view.
world = _world(client.adv_id)
assert world["player"]["hp"] == 100, "the delta pipeline is still wired into turns"
assert world.get("milestones", {}) == {}
assert world.get("flags", {}) in ({}, {"alarm": False})
text = _last_ai_text(client.adv_id)
assert "```state" not in text, "the protocol reached the reader"
assert "goblin's blade" in text
# The raw reply is still kept, so the Insights view and the audit can show
# what the model actually sent.
db = SessionLocal()
try:
snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
snapshot = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
finally:
db.close()
assert "```state" in snap["raw_output"]
assert '"player.hp": -80' in snap["raw_output"]
assert '"player.hp": -80' in snapshot["raw_output"]
def test_action_world_changes_summary(client):
def test_an_old_style_block_proposes_no_narrative_events(client):
"""A delta block is not a typed proposal, and must not be read as one.
`{"player.hp": -80}` is a JSON object with no `events` list. It parses, so
it is not malformed; it simply proposes nothing. What matters is that no
part of it is coerced into an event — a state engine that guessed here would
reintroduce exactly the ambiguity ADR 010 removed.
"""
_play(client)
db = SessionLocal()
try:
changes = db.get(models.Adventure, client.adv_id).actions[-1].world_changes
proposal = (
db.query(models.StateProposal)
.filter_by(adventure_id=client.adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
assert proposal is not None, "no proposal was recorded for the turn"
assert db.query(models.StateEvent).filter_by(
adventure_id=client.adv_id).count() == 0
finally:
db.close()
by_label = {c["label"]: c for c in changes}
assert by_label["hp"]["delta"] == -30 # clamped stat, signed delta
assert by_label["gwen trust"]["delta"] == 15 # npc.<id>.<stat> -> "id stat"
assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True}
assert by_label["win"]["kind"] == "milestone"
def test_world_state_endpoint(client):
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
def test_the_narrative_state_is_what_the_turn_now_establishes(client):
"""And the replacement works on the same campaign, in the same turn shape."""
ScriptedProvider.replies = [
'The blade turns aside.\n```state\n{"events": ['
'{"type": "create_entity", "entity": "gwen", "entity_type": "character",'
' "name": "Gwen"},'
'{"type": "set_entity_status", "entity": "gwen", "status": "wary"}'
']}\n```'
]
_play(client, "parry")
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
body = r.json()
assert body["schema"]["player"]["hp"]["max"] == 100
assert body["state"]["player"]["hp"] == 70
document = r.json()["document"]
assert document["entities"]["gwen"]["status"] == "wary"
def test_override_world_state_endpoint(client):
@@ -160,20 +213,3 @@ def test_override_world_state_endpoint(client):
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
assert r.json()["state"]["player"]["hp"] == 100
def test_undo_reverts_world_state(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 100 # back to initial
assert _world(client.adv_id)["milestones"] == {}
def test_retry_does_not_double_apply(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 70 # not 40