M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.
This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:
visible active transcript position == stored head == authoritative state
Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)
A narrator edit no longer rewrites a row. It returns to the state before the
turn, takes the reader's exact text as the accepted narration, re-derives the
state that text implies, and becomes a new active continuation — while the
original narration keeps its words, its live flag and its whole future as
retained history. At the tip the correction is another take; with story below
it, it forks. No new history machinery: this is the existing fork/take/head
path with the reader's text in place of a generated reply. The §14A refusal
is therefore gone for narrator turns, and remains only for player input.
Pre-M5 positions
Migration 88 backfills the empty narrative document onto every action written
before M5, and a missing snapshot now restores the empty document instead of
leaving the previous position's state standing. Restoring to an old Save
Point no longer leaves a later position's entities and facts on screen.
Narrator context
Replayed history carries prose only; the machine-readable block is no longer
reconstructed into past turns, where it contradicted the authoritative state
in the same prompt. A fact withdrawn by a manual correction is now named as
no longer true, with the reader's reason, rather than silently dropped.
Also
- state_changes joins the action-list bulk read, removing one query per row.
- Extraction takes only the application's own protocol payload: an ordinary
```json or ```python block in a story survives, and a mangled proposal
still does not reach the reader.
Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
+20
-1
@@ -209,7 +209,7 @@ visible from within.
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 698 tests
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 756 tests
|
||||
cd frontend && npm run lint && npm run build
|
||||
```
|
||||
|
||||
@@ -225,6 +225,25 @@ suite as complete evidence.
|
||||
is lost from the union, or if a new HTTP client is added without the shared
|
||||
verification context.
|
||||
|
||||
M5 added `test_narrative_state.py`, which fails if the state stops being
|
||||
genre-neutral, if an event outside the allowlist is ever applied, if a malformed
|
||||
proposal mutates anything, if campaign canon stops outranking the narration, or
|
||||
if a turn's narration and its state can be committed apart from each other.
|
||||
|
||||
`test_narrative_realistic.py` is the one suite that needs a real model, and it is
|
||||
skipped unless you point it at one:
|
||||
|
||||
```bash
|
||||
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \
|
||||
AIDND_TEST_MODEL=qwen2.5:3b-instruct \
|
||||
backend/.venv/bin/python -m pytest backend/tests/test_narrative_realistic.py -v -s
|
||||
```
|
||||
|
||||
It exists because Phase 0B found that structured-state behaviour can look
|
||||
correct on a small prompt and fail under a full one — and it has already earned
|
||||
its place, catching a case where a model echoed its own instruction into the
|
||||
narration.
|
||||
|
||||
M4 added `test_save_points.py`, which fails if restoring a Save Point starts
|
||||
deleting history, stops going through the active head, forks on its own, lets a
|
||||
Save Point on one campaign be restored through another, or lets deleting a branch
|
||||
|
||||
@@ -32,8 +32,10 @@ that isn't the live one starts a new branch.
|
||||
## Features
|
||||
|
||||
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, redo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
|
||||
💭 panel with its own token budget.
|
||||
retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction
|
||||
becomes a new continuation carrying the state it implies, and the original narration keeps its
|
||||
own future as retained history. Reasoning models are supported: "thinking" streams into a
|
||||
collapsible 💭 panel with its own token budget.
|
||||
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
|
||||
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
|
||||
and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||||
@@ -42,13 +44,17 @@ that isn't the live one starts a new branch.
|
||||
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
|
||||
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
|
||||
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
|
||||
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
|
||||
monotonic and milestones sticky, then strips the machine-readable block out of the prose
|
||||
(`backend/app/worldstate/`: `apply.py` clamps, `parse.py` reads the block back).
|
||||
Word-labeled bands (`40–60: minor damage`) make the
|
||||
model reliable at it. No dice and no scripting are required.
|
||||
- **Authoritative narrative state, and the application owns it.** The story tracks who exists,
|
||||
where they are, what they hold, what is true, how they are tied to each other, and what is
|
||||
still open — as generic entities, facts, relationships and threads, with no genre baked in.
|
||||
The same schema holds a silver key in an abbey and a data crystal on an orbital station.
|
||||
The AI proposes **typed events with absolute values** (`set_possession`, `add_fact`,
|
||||
`set_current_location` …), and a Python validator decides what is accepted: unknown event
|
||||
types are refused, references must resolve, campaign canon outranks the narration, and the
|
||||
machine-readable block never reaches the reader (`backend/app/narrative/`). Every accepted
|
||||
change is recorded with what it was before and which turn caused it, so the Story State panel
|
||||
can show what changed and why. You can correct it by hand, and your correction outranks the
|
||||
story.
|
||||
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
|
||||
info) are triggered by keywords in recent story text, then assembled under a token budget
|
||||
(`backend/app/context/builder.py`).
|
||||
@@ -212,7 +218,8 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
|
||||
├─ attempts.py the takes of one turn, grouped by parent
|
||||
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||
├─ narrative/ the authoritative state: typed events, validation, snapshots
|
||||
├─ worldstate/ the inherited RPG stat engine — legacy, no longer authoritative
|
||||
├─ memorybank.py auto-summarization + embedding retrieval
|
||||
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
|
||||
├─ providers/ OpenAI-compatible adapter, streaming
|
||||
@@ -224,7 +231,7 @@ development, Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
698 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
756 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
the model provider mocked. They run with no route to the Internet, which is a requirement
|
||||
rather than a convenience — an offline claim proved on a machine that has been online once
|
||||
proves nothing.
|
||||
|
||||
+49
-7
@@ -38,6 +38,7 @@ from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import models
|
||||
from .context import lineage
|
||||
from .narrative import model as narrative_model
|
||||
|
||||
# The slices of a context snapshot that belong to one attempt rather than to the
|
||||
# turn. They are the world-state delta the attempt proposed and what the engine
|
||||
@@ -45,7 +46,7 @@ from .context import lineage
|
||||
# token accounting. Each attempt is its own API call, and a retry is the call
|
||||
# most likely to read the prompt back out of cache. Everything else in a snapshot
|
||||
# is the prompt, which is assembled once per turn.
|
||||
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
|
||||
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
@@ -152,26 +153,67 @@ def preceding(
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Restores the world state that `node` left behind.
|
||||
"""Restores the state that `node` left behind.
|
||||
|
||||
A NULL snapshot means leave the live state as it is, never reset it. Rows
|
||||
written before SP4 that the migration could not derive an outcome for carry
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
|
||||
the same at any distance: the destination node carries its own outcome, so
|
||||
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
|
||||
M5 changed what is restored, not how — the narrative state document takes
|
||||
the place the RPG world state held, through the same single function.
|
||||
|
||||
The two columns follow **different** rules about a NULL, and the difference
|
||||
is not an oversight.
|
||||
|
||||
For the narrative document, a NULL means *this position established
|
||||
nothing*, and it is restored as the empty document. Leaving the live state
|
||||
alone instead is what the M5 review caught (Finding 3): arriving at a
|
||||
migrated pre-M5 node left a later position's entities, facts and threads
|
||||
standing, so the transcript said depth 2 while the state described depth 6.
|
||||
The invariant this module exists to hold is that the visible position, the
|
||||
head and the authoritative state agree, and "keep whatever was there" cannot
|
||||
hold it. An empty document at an old position is honest — the narrative
|
||||
state system knew nothing then, because it did not exist — where retained
|
||||
state from elsewhere is a claim about a story that had not been told yet.
|
||||
|
||||
Migration backfills those rows explicitly, so this fallback is the belt to
|
||||
that pair of braces: it also covers a node arriving from an older export,
|
||||
which the migration never sees.
|
||||
|
||||
For the legacy RPG world state a NULL still means leave it alone. Those rows
|
||||
predate SP4, nothing consults the values to decide anything, and overwriting
|
||||
a running adventure's numbers with an empty dict would be worse than doing
|
||||
nothing.
|
||||
"""
|
||||
if node is None:
|
||||
return
|
||||
adventure.narrative_state = (
|
||||
copy.deepcopy(node.narrative_state_after)
|
||||
if isinstance(node.narrative_state_after, dict)
|
||||
else narrative_model.empty()
|
||||
)
|
||||
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
|
||||
# coherent with the position being read, so an old save is not left showing
|
||||
# a future's values. Nothing consults them to decide anything.
|
||||
if isinstance(node.world_state_after, dict):
|
||||
adventure.world_state = copy.deepcopy(node.world_state_after)
|
||||
|
||||
|
||||
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Records on `node` the state of the adventure now that the node has played."""
|
||||
"""Records on `node` the state of the adventure now that the node has played.
|
||||
|
||||
Every node, including a player's action that changed nothing. A position
|
||||
without a snapshot is a position the head cannot be restored to, and the
|
||||
head can rest on any node.
|
||||
"""
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
# `state_after` held the scripting engine's shared state, which M2 removed.
|
||||
# The column stays for schema compatibility and is written empty.
|
||||
node.state_after = {}
|
||||
node.world_state_after = copy.deepcopy(world)
|
||||
narrative = adventure.narrative_state
|
||||
node.narrative_state_after = copy.deepcopy(
|
||||
narrative if isinstance(narrative, dict) else narrative_model.empty()
|
||||
)
|
||||
|
||||
|
||||
def roll_back_before(
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}]"
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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("_", " "))
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,6 +106,17 @@ export const api = {
|
||||
}),
|
||||
deleteBranch: (advId, branchId) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
||||
// Narrative state (M5). The browser reads state and proposes corrections; it
|
||||
// never writes state directly. A correction goes through the same validator a
|
||||
// narration's proposal does — the difference is the authority recorded on it.
|
||||
getNarrativeState: (advId) => request(`/adventures/${advId}/state`),
|
||||
correctNarrativeState: (advId, events, note = '') =>
|
||||
request(`/adventures/${advId}/state/corrections`, {
|
||||
method: 'POST', body: JSON.stringify({ events, note }),
|
||||
}),
|
||||
getStateEvents: (advId, limit) =>
|
||||
request(`/adventures/${advId}/state/events${limit ? `?limit=${limit}` : ''}`),
|
||||
|
||||
// Save Points (M4). A Save Point is a durable name for a story position; the
|
||||
// server stores the coordinate and nothing else. Create takes no position —
|
||||
// it is always made at the campaign's active head, which is where the reader
|
||||
|
||||
@@ -23,6 +23,7 @@ import { InsightsPanel } from './panels/InsightsPanel'
|
||||
import { MemoryPanel } from './panels/MemoryPanel'
|
||||
import { PlotPanel } from './panels/PlotPanel'
|
||||
import { SavePointPanel } from './panels/SavePointPanel'
|
||||
import { StatePanel } from './panels/StatePanel'
|
||||
|
||||
const MODES = ['do', 'say', 'story']
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
@@ -69,7 +70,7 @@ export default function Play() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
|
||||
const [panel, setPanel] = useState(null) // null | 'state' | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
|
||||
// Bumped when something outside the turn loop changes the drawers' state
|
||||
// (currently "Update from scenario"), which no action count would reflect.
|
||||
const [stateKey, setStateKey] = useState(0)
|
||||
@@ -453,6 +454,15 @@ export default function Play() {
|
||||
}
|
||||
try {
|
||||
const updated = await api.updateAction(id, actionId, text)
|
||||
// Correcting narrator prose the story is telling is not a rewrite of a
|
||||
// row: STORY-BRANCH-SEMANTICS §§14-15 make it a new continuation, so the
|
||||
// server answers with a different node and the old narration keeps its
|
||||
// future on the line it was written on. The shape of the story changed,
|
||||
// and only the server can say what the transcript is now.
|
||||
if (updated.id !== actionId) {
|
||||
await resync()
|
||||
return
|
||||
}
|
||||
// A take that is only being read is not in `actions` — the row there is
|
||||
// the live one — so the new text goes back into the preview, which is
|
||||
// what that row is drawing. The pager holds the take list it fetched, so
|
||||
@@ -464,6 +474,10 @@ export default function Play() {
|
||||
} else {
|
||||
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
|
||||
}
|
||||
// Editing a take the story is not telling changes that take's words and
|
||||
// nothing else, but the panel is keyed on this and would otherwise keep
|
||||
// drawing what it last read.
|
||||
setStateKey((k) => k + 1)
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
@@ -540,6 +554,8 @@ export default function Play() {
|
||||
<div className="page-header">
|
||||
<h1>{adventure.title}</h1>
|
||||
<div className="panel-toggles">
|
||||
<button className={panel === 'state' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'state' ? null : 'state')}>Story State</button>
|
||||
<button className={panel === 'plot' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
|
||||
<button className={panel === 'memory' ? 'active' : ''}
|
||||
@@ -763,12 +779,22 @@ export default function Play() {
|
||||
<div className="side-panel">
|
||||
<div className="side-panel-header">
|
||||
<h2>{{
|
||||
plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches',
|
||||
savepoints: 'Save Points', insights: 'Insights',
|
||||
state: 'Story State', plot: 'Plot Components', memory: 'Memory Bank',
|
||||
branches: 'Branches', savepoints: 'Save Points', insights: 'Insights',
|
||||
}[panel]}</h2>
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
{panel === 'state' ? (
|
||||
// Keyed on the same signal the other live panels use, so the state
|
||||
// follows Undo, Redo and a Save Point restore — what it shows is
|
||||
// the state at the position being read, not at the newest turn.
|
||||
<StatePanel
|
||||
advId={id}
|
||||
refreshKey={`${actions.length}:${stateKey}`}
|
||||
onCorrected={() => setStateKey((k) => k + 1)}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
) : panel === 'plot' ? (
|
||||
<PlotPanel adventure={adventure} setAdventure={setAdventure}
|
||||
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
|
||||
) : panel === 'memory' ? (
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// The story's current state: what the campaign believes right now.
|
||||
//
|
||||
// M5's inspector foundation. Deliberately small — M8 owns the polished state
|
||||
// editor — but real: it shows the authoritative state at the position being
|
||||
// read, and it can correct it.
|
||||
//
|
||||
// Two rules shape it.
|
||||
//
|
||||
// The browser is a presentation layer. It renders what the server groups and
|
||||
// sends; it does not decide what is true, and it does not write state directly.
|
||||
// A correction is proposed as typed events and goes through the same validator a
|
||||
// narration's proposal does, so a typo here is refused the same way a model's
|
||||
// would be.
|
||||
//
|
||||
// And what is shown is the state at the **active head**, not at the newest turn.
|
||||
// Undo, Redo and a Save Point restore all move where the story is being read,
|
||||
// and the panel follows — so after stepping back, the reader sees what was true
|
||||
// then rather than what the campaign later became.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
|
||||
function StatePanel({ advId, refreshKey, onCorrected, onError }) {
|
||||
const [state, setState] = useState(null)
|
||||
const [failed, setFailed] = useState(null)
|
||||
const [correcting, setCorrecting] = useState(null) // { key, label, text }
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
const [history, setHistory] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFailed(null)
|
||||
api.getNarrativeState(advId)
|
||||
.then((body) => { if (!cancelled) setState(body) })
|
||||
.catch((err) => { if (!cancelled) setFailed(err.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [advId, refreshKey, tick])
|
||||
|
||||
async function showHistory() {
|
||||
if (history) { setHistory(null); return }
|
||||
try {
|
||||
setHistory(await api.getStateEvents(advId, 40))
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// A correction is expressed as a fact the reader asserts. That is the one
|
||||
// shape a person can write without knowing the event vocabulary, and it is
|
||||
// enough for the correction C04 asks for — "Mara never learned where the
|
||||
// silver key was found" is a fact about Mara.
|
||||
async function saveCorrection(event) {
|
||||
event.preventDefault()
|
||||
const text = (correcting?.text || '').trim()
|
||||
if (!text || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const events = [{
|
||||
type: 'add_fact',
|
||||
predicate: text,
|
||||
...(correcting.key ? { subject: correcting.key } : {}),
|
||||
}]
|
||||
await api.correctNarrativeState(advId, events, text)
|
||||
setCorrecting(null)
|
||||
setTick((t) => t + 1)
|
||||
// The corrected state is what the next turn is built from, so anything
|
||||
// showing the old value has to re-read.
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(factId) {
|
||||
try {
|
||||
await api.correctNarrativeState(
|
||||
advId, [{ type: 'invalidate_fact', fact_id: factId }])
|
||||
setTick((t) => t + 1)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) return <div className="panel-empty">Couldn’t read the story state — {failed}</div>
|
||||
if (!state) return <div className="panel-empty">Reading the story state…</div>
|
||||
|
||||
return (
|
||||
<div className="state-panel">
|
||||
{state.empty && (
|
||||
<p className="state-intro">
|
||||
Nothing established yet. As the story names people, places and things,
|
||||
and moves them around, what the story believes appears here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.groups.map((group) => (
|
||||
<div key={group.title} className="state-group">
|
||||
<h3>{group.title}</h3>
|
||||
{group.rows.map((row, i) => (
|
||||
<div key={`${row.key}-${i}`} className="state-row">
|
||||
<div className="state-label">{row.label}</div>
|
||||
{row.detail && <div className="state-detail">{row.detail}</div>}
|
||||
<div className="state-tools">
|
||||
{/* Correcting is offered against a named thing, so the
|
||||
correction can say who it is about. */}
|
||||
{row.key && (
|
||||
<button type="button"
|
||||
onClick={() => setCorrecting({ key: row.key, label: row.label, text: '' })}>
|
||||
Correct
|
||||
</button>
|
||||
)}
|
||||
{group.title === 'Important Facts' && row.key && (
|
||||
<button type="button" onClick={() => withdraw(row.key)}>
|
||||
That’s wrong
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="state-correction">
|
||||
{correcting ? (
|
||||
<form onSubmit={saveCorrection}>
|
||||
<label htmlFor="state-correction-text">
|
||||
{correcting.key
|
||||
? `Set the story straight about ${correcting.label}`
|
||||
: 'Set the story straight'}
|
||||
</label>
|
||||
<input
|
||||
id="state-correction-text"
|
||||
autoFocus
|
||||
maxLength={500}
|
||||
placeholder="never learned where the silver key was found"
|
||||
value={correcting.text}
|
||||
disabled={saving}
|
||||
onChange={(e) => setCorrecting({ ...correcting, text: e.target.value })}
|
||||
/>
|
||||
<div className="state-tools">
|
||||
<button type="submit" className="primary" disabled={saving || !correcting.text.trim()}>
|
||||
Save correction
|
||||
</button>
|
||||
<button type="button" onClick={() => setCorrecting(null)}>Cancel</button>
|
||||
</div>
|
||||
<p className="state-hint">
|
||||
This becomes what the storyteller works from. It does not change
|
||||
anything already written.
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" className="state-correct-open"
|
||||
onClick={() => setCorrecting({ key: '', label: '', text: '' })}>
|
||||
Correct something
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="state-history-open" onClick={showHistory}>
|
||||
{history ? '▾ Hide what changed' : '▸ What changed, and why'}
|
||||
</button>
|
||||
{history && (
|
||||
<div className="state-history">
|
||||
{history.length === 0 && <p className="state-hint">Nothing recorded yet.</p>}
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="state-history-row">
|
||||
<span className="state-history-what">{describe(entry)}</span>
|
||||
<span className="state-history-when">
|
||||
{entry.turn ? `moment ${entry.turn}` : 'before the story'}
|
||||
{entry.source === 'manual_correction' && ' · your correction'}
|
||||
{entry.source === 'narrator_edit' && ' · your edit'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One accepted event, in a sentence. The payload is the server's own record of
|
||||
// what happened, so this reads it rather than re-deriving anything.
|
||||
function describe(entry) {
|
||||
const p = entry.payload || {}
|
||||
switch (entry.event_type) {
|
||||
case 'create_entity': return `${p.name || p.entity} enters the story`
|
||||
case 'set_entity_status': return `${p.entity} is ${p.status}`
|
||||
case 'set_entity_attribute': return `${p.entity} ${p.attribute} = ${p.value}`
|
||||
case 'set_entity_conditions': return `${p.entity}: ${(p.conditions || []).join(', ') || 'nothing'}`
|
||||
case 'set_current_location': return `${p.entity} is at ${p.location}`
|
||||
case 'set_possession': return `${p.owner} holds ${p.item}`
|
||||
case 'clear_possession': return `${p.item} is held by nobody`
|
||||
case 'add_fact': return [p.subject, p.predicate, p.object, p.value].filter(Boolean).join(' ')
|
||||
case 'invalidate_fact': return `withdrew ${p.fact_id}${p.reason ? ` — ${p.reason}` : ''}`
|
||||
case 'add_relationship': return `${p.source} ${p.relationship} ${p.target}`
|
||||
case 'end_relationship': return `${p.source} no longer ${p.relationship} ${p.target}`
|
||||
case 'open_story_thread': return `opened: ${p.title || p.thread}`
|
||||
case 'resolve_story_thread': return `resolved: ${p.thread}`
|
||||
case 'set_scene': return `scene: ${p.summary || p.location || ''}`
|
||||
default: return entry.event_type
|
||||
}
|
||||
}
|
||||
|
||||
export { StatePanel }
|
||||
@@ -236,10 +236,11 @@ export default function ScenarioEditor() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Optional. Define stats (with bands and rules), flags, and milestones, and the AI
|
||||
will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
|
||||
part of this same schema but have their own section below. Leave blank for a plain
|
||||
narrative scenario.
|
||||
Optional, and no longer how the story is tracked. Every campaign now keeps
|
||||
genre-neutral narrative state — who exists, where they are, what they hold,
|
||||
what has been established — without any schema at all, and that is what the
|
||||
narrator is told. Numbers defined here are recorded alongside it for
|
||||
scenarios that were built on them. Leave blank unless you have one.
|
||||
</p>
|
||||
{schemaView === 'form' ? (
|
||||
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
||||
|
||||
@@ -410,3 +410,93 @@
|
||||
}
|
||||
.mode-select button.active + button { border-left-width: 0; }
|
||||
|
||||
/* Story State (M5). The same furniture as the other panels: what the campaign
|
||||
believes is not a different species of thing from the lines it believes them
|
||||
on, and a reader moving between panels should not have to relearn the shapes. */
|
||||
.state-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
.state-intro, .state-hint {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.state-group h3 {
|
||||
margin: 0;
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
font-weight: normal;
|
||||
}
|
||||
.state-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.state-label {
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.94rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.state-detail {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state-tools { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||||
.state-tools button {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.state-tools button:hover:not(:disabled) {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.state-tools button:disabled { opacity: 0.4; cursor: default; }
|
||||
.state-correction { display: flex; flex-direction: column; gap: 7px; }
|
||||
.state-correction form { display: flex; flex-direction: column; gap: 7px; }
|
||||
.state-correction label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.state-correction input {
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.state-correct-open, .state-history-open {
|
||||
align-self: flex-start;
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.state-correct-open:hover, .state-history-open:hover {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.state-history { display: flex; flex-direction: column; gap: 4px; }
|
||||
.state-history-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 5px 9px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.state-history-what { font-size: 0.8rem; color: var(--text); }
|
||||
.state-history-when {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Adventure Storyteller — Production Build Milestones
|
||||
|
||||
**Status:** In implementation. M1-M4 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03); M5 — Genre-Neutral Authoritative Narrative State — next to brief
|
||||
**Status:** In implementation. M1-M5 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04, after an independent review and a corrective pass); M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory — next to brief
|
||||
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
|
||||
|
||||
## 1. Purpose
|
||||
@@ -591,6 +591,52 @@ treat the edited text as accepted output, re-evaluate the implied state, create
|
||||
new continuation, and retain the original. The refusal in §14A is then replaced
|
||||
by that behavior rather than kept alongside it.
|
||||
|
||||
**Delivered in the M5 corrective pass.** The first M5 implementation did the
|
||||
state half only and kept editing the row in place, which the independent review
|
||||
found broke the head/state invariant. A narrator edit now forks — the original
|
||||
narration and its whole future are retained untouched, and the correction
|
||||
becomes a new active continuation carrying its own re-derived state. The §14A
|
||||
refusal is gone for narrator turns and remains only for a player's own input
|
||||
(§13), which M5 did not change.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Outcome
|
||||
|
||||
**Complete and accepted, 2026-09-04**, after an independent implementation
|
||||
review (`planning/reports/M5-IMPLEMENTATION-REPORT.md`) and the corrective pass
|
||||
recorded in that report's addendum.
|
||||
|
||||
Delivered:
|
||||
|
||||
- Genre-neutral typed narrative state per ADR 010, with the authoritative
|
||||
document shape now recorded in
|
||||
[ADR 013](DECISIONS/013-authoritative-narrative-state-document.md).
|
||||
- **D10 complete.** A narrator edit returns to the state before the turn, uses
|
||||
the reader's exact text, re-derives the state it implies, becomes a new active
|
||||
continuation, and retains the original narration and its future
|
||||
(`STORY-BRANCH-SEMANTICS.md` §§14-15).
|
||||
- **Pre-M5 compatibility.** Migration 88 backfills the empty narrative document
|
||||
onto every action written before M5, and a missing snapshot restores the empty
|
||||
document rather than leaving a later position's state standing. Old campaigns,
|
||||
Save Points, branches and transcripts stay usable, and no legacy RPG machinery
|
||||
becomes authoritative again.
|
||||
- **Manual corrections govern the narrator's context.** Replayed history carries
|
||||
prose only, and a withdrawn fact is named as no longer true rather than
|
||||
silently dropped.
|
||||
|
||||
Debt carried forward, deliberately:
|
||||
|
||||
- **M8 (browser UX):** the scenario editor still exposes the legacy RPG stat
|
||||
schema. Its copy no longer claims that schema is how the story is tracked, but
|
||||
the screen itself is M8's to redesign.
|
||||
- **M9 (export/recovery):** `state_events` and `state_proposals` are not carried
|
||||
in an export, so an imported campaign keeps a correction's effect but not its
|
||||
audit trail. C04 therefore passes for a live campaign and not across a round
|
||||
trip.
|
||||
- **§13 (editing player input)** is still an in-place edit guarded by the
|
||||
off-screen refusal. Bringing it onto the §§14-15 footing was out of M5's scope.
|
||||
|
||||
---
|
||||
|
||||
# M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR 013 — The Authoritative Narrative State Document
|
||||
|
||||
**Status:** Accepted; implemented in M5
|
||||
**Date:** 2026-09-04
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 010](010-explicit-typed-narrative-state-events.md) settled the **protocol**:
|
||||
the model proposes change as explicit, typed, absolute events drawn from a fixed
|
||||
allowlist, never as relative deltas. It did not settle what those events write
|
||||
into.
|
||||
|
||||
That shape turned out to be load-bearing. The prompt renders it, the browser's
|
||||
state panel groups it, export carries it, migration has to produce it for
|
||||
positions that predate it, and every Undo, Redo, take switch and Save Point
|
||||
restore reads a copy of it. The M5 review recommended recording it as a decision
|
||||
rather than leaving it an implementation detail of `narrative/model.py`. This ADR
|
||||
records what was built; it does not extend it.
|
||||
|
||||
## Decision
|
||||
|
||||
### The document
|
||||
|
||||
One JSON document is the campaign's authoritative account of its own story. It
|
||||
is genre-neutral: nothing in it names a stat, a level, a currency or a class.
|
||||
|
||||
```text
|
||||
entities who and what exists — people, places, things, groups.
|
||||
Each carries a name, a type, aliases, a description, a status,
|
||||
free-form attributes, conditions, and a current location.
|
||||
facts what has been established, as subject/predicate/object.
|
||||
Each carries an id, a status, and where it came from.
|
||||
relationships how entities stand to one another, directionally.
|
||||
possessions which entity holds which item.
|
||||
threads open story threads, with a title and a status.
|
||||
scene where the story is now, and a short summary of the moment.
|
||||
```
|
||||
|
||||
Two fields appear throughout and are the reason the document can be trusted:
|
||||
|
||||
- **authority** — who established this: the story, or the reader's own
|
||||
correction. A reader's correction outranks the narration, and the prompt says
|
||||
so in words.
|
||||
- **provenance** — the branch and depth the change was made at, so a fact can be
|
||||
traced to the moment it entered the story.
|
||||
|
||||
Nothing is deleted. A fact a correction takes back is marked `invalidated`, with
|
||||
the reason and the position, because a record that vanished would audit nothing.
|
||||
|
||||
### The pipeline
|
||||
|
||||
```text
|
||||
model output
|
||||
-> extraction the protocol block is separated from the prose
|
||||
-> validation envelope, allowlist, schema, references, canon
|
||||
-> validated events recorded in `state_events`, with their provenance
|
||||
-> the document applied to produce the new authoritative state
|
||||
-> per-position snapshot stored on the node the turn produced
|
||||
```
|
||||
|
||||
Each stage has one job, and the order is deliberate: the allowlist is checked
|
||||
before any field is read, so an unknown event type is rejected before its
|
||||
contents are touched.
|
||||
|
||||
### Where it lives
|
||||
|
||||
- `adventures.narrative_state` — the current authoritative document. This is
|
||||
what the narrator is told and what the reader is shown.
|
||||
- `actions.narrative_state_after` — the whole document as it stood after that
|
||||
position played, on **every** node.
|
||||
- `state_events` / `state_proposals` — the audit: what was proposed, what was
|
||||
accepted, what was refused and why.
|
||||
|
||||
### What each is for
|
||||
|
||||
**Event history is audit and provenance, not a source of truth.** Restoring a
|
||||
position never replays it. This was proven by renaming the table away
|
||||
mid-campaign: Undo and Redo continued to work.
|
||||
|
||||
**Snapshots are what make restore bounded.** Arriving at a position is a single
|
||||
row read whose cost does not grow with the length of the story (ADR 012 §10.4).
|
||||
A position without a snapshot is a position the head cannot be restored to, so
|
||||
every node has one — including nodes written before M5, which migration 88
|
||||
backfills with the empty document. A missing snapshot restores the empty
|
||||
document rather than leaving the previous position's state standing.
|
||||
|
||||
**The document is the authority; the model only proposes.** Every event, whether
|
||||
it came from the model or from a reader's correction, passes the same
|
||||
validation. The model cannot write a field directly, cannot invent an event
|
||||
type, and cannot refer to an entity that does not exist in this campaign.
|
||||
|
||||
## The invariant
|
||||
|
||||
```text
|
||||
visible active transcript position == stored head == authoritative state
|
||||
```
|
||||
|
||||
Everything above exists to hold this. The M5 review found two ways it had been
|
||||
broken — a narrator edit that rewound live state while the head stayed at the
|
||||
tip, and a restore to a migrated position that left a later position's state
|
||||
standing — and both were fixed by making the rule absolute rather than by adding
|
||||
a special case.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The document is larger than the RPG dict it replaced, so both it and the
|
||||
per-node snapshots are stored compressed.
|
||||
- Genre lives in the campaign's canon and in the words the story uses, never in
|
||||
the schema. A science-fiction campaign and a fantasy one produce the same
|
||||
shapes.
|
||||
- A reader's correction is durable and outranks narration, and the prompt states
|
||||
both what holds and what has been withdrawn.
|
||||
- Export carries the document, the canon and every snapshot. It does **not**
|
||||
yet carry `state_events` / `state_proposals`, so an imported campaign keeps a
|
||||
correction's effect but not its audit trail. Deferred to M9.
|
||||
@@ -377,37 +377,69 @@ Therefore the system must:
|
||||
|
||||
The system must not simply replace visible text while leaving stale state behind.
|
||||
|
||||
## 14A. Editing In Place, Before §14-15 Are Implemented
|
||||
### How this is built (M5 corrective pass)
|
||||
|
||||
§14 and §15 describe the finished behavior: a narrator edit becomes
|
||||
authoritative, the state it implies is re-evaluated, a new continuation is
|
||||
created, and the original narration and its future are retained. That
|
||||
requirement stands in full and is **not** weakened by this section.
|
||||
All five conditions are implemented. A narrator edit is not a write to the row
|
||||
being corrected — nothing on the line being left is written to at all. It is the
|
||||
same operation as playing the turn again from here, with the reader's words in
|
||||
place of a generated reply, and it reuses the fork, take, head and snapshot
|
||||
machinery M3 and M4 already provide rather than introducing a second history
|
||||
mechanism.
|
||||
|
||||
It is not yet built. Re-evaluating the state implied by prose a user typed
|
||||
requires the authoritative narrative-state extraction that the genre-neutral
|
||||
state milestone introduces, so the finished behavior is completed there. What
|
||||
exists in the meantime is a plain correction: it changes the words of one turn
|
||||
and re-evaluates nothing.
|
||||
```text
|
||||
before after
|
||||
|
||||
That correction is safe while everything descending from the turn is on screen,
|
||||
because the user can see what their change has to stay consistent with. It is
|
||||
not safe when a continuation descends from the turn and is **off screen** —
|
||||
undone and not yet redone, or left behind by a divergence — because the edit
|
||||
would then silently change the words that retained story was written from, and
|
||||
nothing on screen would show it. Retained history is not permitted to be made to
|
||||
disagree with itself in a way the user cannot see.
|
||||
parent parent
|
||||
└── original narrator ├── original narrator ──> old future [retained]
|
||||
└── old future └── corrected narrator [active]
|
||||
```
|
||||
|
||||
Until §14-15 are implemented, the system must therefore **refuse** an in-place
|
||||
edit of a turn that has story descending from it which is not currently being
|
||||
shown, and say why. The user resolves it the same two ways as §10:
|
||||
Two shapes, chosen by whether anything was written after the turn:
|
||||
|
||||
- **Redo**, bringing the later story back into view; or
|
||||
- **play the turn again from here**, which is the §13 shape — return to the
|
||||
parent position, continue differently, and keep the old line as retained
|
||||
history.
|
||||
- **Nothing below it.** The attempts at that turn are still leaves, so the
|
||||
correction joins them as another take and the original stays beside it in the
|
||||
pager. No branch is created, and the head does not move.
|
||||
- **A story below it**, on screen or not. That story 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 departed line keeps its node,
|
||||
its future and its live flag.
|
||||
|
||||
Refusing is the minimum that keeps the invariant. It is not the destination.
|
||||
The state the correction implies is derived from the snapshot on the node
|
||||
*before* the edited turn — one row read, not a replay — and put through the same
|
||||
validation the model's own proposals face. The correction's node then carries
|
||||
that document as its own outcome, so the campaign's live state and the state
|
||||
stored at the head are the same document. That equality is the invariant:
|
||||
|
||||
```text
|
||||
visible active transcript position == stored head == authoritative state
|
||||
```
|
||||
|
||||
The M5 review found it broken by the interim implementation, which rewrote the
|
||||
row in place and rewound the campaign's live state to that position while the
|
||||
head stayed at the tip. 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.
|
||||
|
||||
## 14A. Editing In Place — Superseded for Narrator Output
|
||||
|
||||
§§14-15 are implemented, and the refusal this section described no longer
|
||||
applies to narrator turns. It is kept because it explains why the current shape
|
||||
is the one it is.
|
||||
|
||||
The refusal existed because an in-place edit rewrote the words that a
|
||||
descending, invisible story had been written from, and retained history is not
|
||||
permitted to be made to disagree with itself where the user cannot see it. A
|
||||
fork removes the premise: the off-screen future keeps the exact narration it
|
||||
descends from, so there is nothing left to refuse. What was the unsafe case is
|
||||
now an ordinary one.
|
||||
|
||||
Two in-place edits remain, and neither can produce that disagreement:
|
||||
|
||||
- **A player's own input** (§13) is still edited in place, and is still refused
|
||||
while a story descends from it off screen. Bringing §13 onto the same footing
|
||||
as §§14-15 is not yet done.
|
||||
- **A take the story is not telling** has no continuation of its own — keeping
|
||||
one is what forking is for — so correcting its words contradicts nothing.
|
||||
|
||||
## 16. Manual State / Canon Correction
|
||||
|
||||
|
||||
@@ -517,6 +517,31 @@ Mara never learned where the silver key was found.
|
||||
- correction is auditable,
|
||||
- old transcript is not silently rewritten unless explicitly edited.
|
||||
|
||||
### Result — PASS for the live campaign (M5 corrective pass, 2026-09-04)
|
||||
|
||||
The M5 review found the first condition failing: the state section dropped a
|
||||
withdrawn fact and the replayed history handed it straight back as an accepted
|
||||
event, in the protocol's own words, with nothing saying it had been corrected.
|
||||
Two changes fixed it, and both are pinned by
|
||||
`test_c04_a_withdrawn_fact_does_not_come_back_through_history`:
|
||||
|
||||
- replayed history is prose only — the machine-readable block is no longer
|
||||
reconstructed into past turns, so a turn's record of what was true *then*
|
||||
cannot contradict what is authoritative now;
|
||||
- a withdrawn fact is named in the state section under "No longer true — do not
|
||||
treat these as established", with the reader's reason, rather than silently
|
||||
omitted. Omitting it left the narration that first asserted it as the only
|
||||
account in the prompt.
|
||||
|
||||
The old transcript is not rewritten: an invalidated fact stays in the document
|
||||
with its status, its reason and its provenance.
|
||||
|
||||
**Carried debt, deferred to M9 (export/recovery):** a campaign's `state_events`
|
||||
and `state_proposals` are not carried in an export, so an imported copy keeps
|
||||
the correction's *effect* — the fact is still marked `manual_correction` — but
|
||||
reports zero audit events. The auditable condition therefore holds for a live
|
||||
campaign and not across a round trip.
|
||||
|
||||
---
|
||||
|
||||
## C05 — Canon Beats Reference
|
||||
@@ -721,25 +746,41 @@ Mara wears a green cloak.
|
||||
- downstream state is re-evaluated,
|
||||
- old version/future remains retained/disposable.
|
||||
|
||||
### Milestone ownership
|
||||
### Result — PASS (M5 corrective pass, 2026-09-04)
|
||||
|
||||
All three pass conditions stand for v1. They are delivered across three
|
||||
milestones, and this note records which is which rather than reducing the
|
||||
requirement:
|
||||
All three pass conditions are met, and each was demonstrated rather than
|
||||
inferred.
|
||||
|
||||
- **Edit becomes authoritative on the active path.** The reader's text is stored
|
||||
verbatim, with only the protocol block stripped, and no model is called
|
||||
(`test_the_corrected_text_is_used_verbatim_not_regenerated`).
|
||||
- **Downstream state is re-evaluated.** The state is derived from the snapshot on
|
||||
the node before the corrected turn and put through the normal validation path,
|
||||
and the campaign's live document then equals the document stored at the head
|
||||
(`test_editing_a_narrator_turn_with_visible_descendants_forks`, and the
|
||||
live-state/head-snapshot assertion carried by every test in that group).
|
||||
- **Old version/future remains retained/disposable.** Nothing on the departed
|
||||
line is written to: the original node keeps its words and its live flag, and
|
||||
every row played after it still exists
|
||||
(`test_the_old_narration_and_its_future_leave_the_active_transcript`,
|
||||
`test_editing_a_narrator_turn_with_an_undone_future_keeps_it`).
|
||||
|
||||
Verified in a real browser on the case the M5 review reproduced as broken:
|
||||
correcting the earliest narrator turn with four turns of story on screen, then
|
||||
checking the transcript, the retained rows, the state panel, the live document
|
||||
against the head snapshot, Undo/Redo, and the next turn's assembled prompt —
|
||||
16 of 16 checks.
|
||||
|
||||
The delivery history, kept because it explains the shape:
|
||||
|
||||
- **M3 — safe history behavior.** Replaying a narrator turn with different text
|
||||
forks, keeps the original take and its future, and starts the new continuation
|
||||
from the correct earlier state. In-place editing of a turn is **refused** while
|
||||
story descends from it off screen, so retained history cannot be made to
|
||||
contradict itself unseen (`STORY-BRANCH-SEMANTICS.md` §14A). Delivered.
|
||||
- **M5 — authoritative state re-evaluation.** The second pass condition. Making a
|
||||
hand-typed narrator correction authoritative and re-evaluating the state it
|
||||
implies requires the narrative-state extraction pass, so it is completed there,
|
||||
and the §14A refusal is replaced by it. Outstanding.
|
||||
- **Later browser UX work — the finished editing workflow.** How the user reaches
|
||||
and confirms the operation. Outstanding.
|
||||
|
||||
D10 is therefore **not** satisfied at the end of M3, and is not scored as such.
|
||||
forks and keeps the original take and its future. In-place editing was
|
||||
*refused* while story descended from a turn off screen.
|
||||
- **M5 — authoritative state re-evaluation**, and the refusal replaced. A
|
||||
narrator edit now forks rather than rewriting a row, so the off-screen case it
|
||||
refused is simply handled (`STORY-BRANCH-SEMANTICS.md` §§14-15).
|
||||
- **Later browser UX work.** How the reader reaches and confirms the operation
|
||||
is M8's; the operation itself is complete and reachable through the ✎ control.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ Key v2 changes include:
|
||||
- AI-DnD selected as the production base at the pinned Phase 0B commit.
|
||||
- Non-destructive head-cursor Undo/Redo design selected.
|
||||
- Explicit typed/absolute narrative-state events selected for production state handling.
|
||||
- The authoritative narrative-state document — its shape, its authority/provenance fields, and the
|
||||
event/document/snapshot split — recorded in ADR 013 after M5 implemented it.
|
||||
- Imported knowledge separated from AI-DnD Story Cards.
|
||||
- Trusted-LAN Ollama inference supported in v1 while the storyteller UI/API remains loopback-bound by default.
|
||||
- Offline first-use dependencies and runtime remote assets identified as M1 hardening work.
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
# M5 Implementation Review — Typed Narrative State
|
||||
|
||||
**Reviewed:** 2026-09-03 / 2026-09-04
|
||||
**Branch:** `m5-narrative-state` **Base commit:** `62a997f` (M4 closeout)
|
||||
**Subject of review:** the staged, uncommitted M5 working tree
|
||||
**Verdict:** PASS WITH CORRECTIVE WORK REQUIRED
|
||||
|
||||
---
|
||||
|
||||
## A. Scope
|
||||
|
||||
This reviews the M5 milestone: replacing the RPG relative-delta world-state
|
||||
mechanism with genre-neutral typed narrative state per ADR 010, and making that
|
||||
state the authoritative thing the narrator is told and the reader is shown.
|
||||
|
||||
It is a review, not a closeout and not implementation. No application code was
|
||||
changed. Defects found are recorded here rather than fixed, and the working tree
|
||||
is exactly as the implementation left it.
|
||||
|
||||
M6 was not started.
|
||||
|
||||
## B. Method and independence
|
||||
|
||||
The M5 implementation summary was treated as a claim, not as evidence. Every
|
||||
acceptance statement below rests on something run during this review:
|
||||
|
||||
- the full backend suite, re-run from scratch;
|
||||
- purpose-built probes that drive the real FastAPI application over its real
|
||||
HTTP surface against a real SQLite file, with a scripted narrator;
|
||||
- a real Firefox 154.0.1 driven over WebDriver against the real built frontend
|
||||
served by the real backend;
|
||||
- the real model endpoint on the trusted LAN, for the tests that are skipped
|
||||
without one;
|
||||
- a real `docker build`.
|
||||
|
||||
Where a check could only be done with a mock, it is labelled as such. The
|
||||
atomicity check (L01) specifically was **not** done with mocks — the failure was
|
||||
induced at the storage layer.
|
||||
|
||||
## C. Repository state
|
||||
|
||||
```
|
||||
branch m5-narrative-state
|
||||
HEAD 62a997f
|
||||
staged 45 files changed, 4979 insertions(+), 364 deletions(-)
|
||||
12 added, 33 modified
|
||||
unstaged / untracked none
|
||||
```
|
||||
|
||||
The tree was in this state at the start of the review and is in this state at
|
||||
the end of it. No secrets, databases, or browser profiles are staged. Upstream
|
||||
ancestry is intact and `LICENSE` is unmodified.
|
||||
|
||||
The new package is `backend/app/narrative/` — `model.py`, `events.py`,
|
||||
`validate.py`, `apply.py`, `extract.py`, `render.py`, `store.py` — plus
|
||||
`backend/tests/test_narrative_state.py` and `test_narrative_realistic.py`.
|
||||
|
||||
## D. What M5 changed
|
||||
|
||||
The relative-delta protocol (`{"player.hp": -15}`) is gone from the prompt path.
|
||||
In its place is a 14-event allowlist of explicit, **absolute** narrative events
|
||||
(`create_entity`, `set_entity_attribute`, `set_possession`,
|
||||
`set_current_location`, `add_fact`, `invalidate_fact`, `open_story_thread`, …)
|
||||
carried in a fenced block the extraction pass strips out of the prose.
|
||||
|
||||
State is stored twice on purpose, per DATA-MODEL §17: validated events in
|
||||
`state_events` for audit, and a whole-document snapshot per position in
|
||||
`actions.narrative_state_after` for restore. The live document sits in
|
||||
`adventures.narrative_state`.
|
||||
|
||||
The legacy world-state module still exists and is still exercised by its own
|
||||
tests, but it no longer reaches the assembled prompt. That demotion is verified
|
||||
(§I below), not assumed.
|
||||
|
||||
## E. ADR 010 conformance
|
||||
|
||||
Conformant, and the central ambiguity ADR 010 exists to remove is genuinely
|
||||
removed. There is no code path where one numeric value can be read as either an
|
||||
absolute or a delta: `apply.py` is an explicit `if/elif` chain over event types,
|
||||
each writing a named field, with no arithmetic on prior values.
|
||||
|
||||
The state document is genre-neutral. A scan of the new package for RPG and
|
||||
fantasy vocabulary (`dungeon`, `hp`, `mana`, `quest`, `loot`, `potion`, …)
|
||||
returns nothing but one citation of AI-DnD in a module docstring explaining what
|
||||
ADR 010 replaced.
|
||||
|
||||
## F. Validation and the security boundary
|
||||
|
||||
`validate.py` is five layers — envelope, allowlist, schema, referential,
|
||||
semantic — and the allowlist is checked before anything else touches the
|
||||
payload. This is the right order: an unknown event type is rejected before its
|
||||
fields are read.
|
||||
|
||||
**H05 holds.** Hostile proposals were fed through the real turn pipeline:
|
||||
`execute_shell`, an `eval` event carrying `__import__('os').system(...)`, an
|
||||
event keyed `event_type` instead of `type` to test envelope confusion, an entity
|
||||
named `__class__`, and a story-thread id of `../../etc/passwd`. No marker file
|
||||
was created, no event was accepted, and the proposal was recorded with its
|
||||
rejection reasons. Nothing in the event path reaches the filesystem, a
|
||||
subprocess, or an attribute lookup on a Python object.
|
||||
|
||||
Cross-campaign references are refused: an event naming an entity that exists in
|
||||
a *different* campaign is rejected as `unknown_reference` rather than resolved.
|
||||
|
||||
## G. Head, lineage, and restore
|
||||
|
||||
**Restore does not replay the event log.** This was proven, not inferred: the
|
||||
`state_events` table was renamed away mid-campaign, and undo and redo both still
|
||||
returned 200 and moved the state correctly. Restore is a snapshot row lookup, as
|
||||
ADR 012 requires. Query counts for undo were also measured at two depths and did
|
||||
not grow.
|
||||
|
||||
**Lineage isolation holds (E01, E04).** State established on a line that is then
|
||||
abandoned does not appear in the new line's state document, and does not appear
|
||||
in the next turn's assembled prompt.
|
||||
|
||||
## H. Atomicity (L01)
|
||||
|
||||
Tested by induced failure, not by mocks: the `state_events` table was renamed
|
||||
away underneath a live state-writing turn.
|
||||
|
||||
```
|
||||
before head (1,2) mara.cloak = red
|
||||
state_events taken away
|
||||
turn OperationalError: no such table: state_events
|
||||
after head (1,3) mara.cloak = red
|
||||
accepted narration rows for the failed turn: 0
|
||||
previous story reachable: yes
|
||||
recovery after the table returns: next turn 200, state advances normally
|
||||
```
|
||||
|
||||
No half-written state, no accepted narration without it, no inaccessible
|
||||
history. The head advanced by exactly one, onto the player's own submitted text
|
||||
— which the L01 note explicitly defines as correct under A05, not as a
|
||||
half-advanced head. **L01 PASS.**
|
||||
|
||||
## I. Context assembly
|
||||
|
||||
The narrator is given the state document rendered as prose, not as JSON, and the
|
||||
legacy world-state block never appears. Verified by inspecting stored
|
||||
`context_snapshot` rows from real turns.
|
||||
|
||||
One defect here, and it is the mechanism behind Finding 4: the **history**
|
||||
section replays each past narrator turn's raw text *including its ```state
|
||||
fence*. So the prompt simultaneously instructs the model that prose must not
|
||||
contain protocol, and shows it several past examples where prose does.
|
||||
|
||||
## J. Manual corrections (C04)
|
||||
|
||||
A correction lands, is stored with `manual_correction` authority, is shown in
|
||||
the inspector marked as the reader's own, and is visible in the audit view.
|
||||
|
||||
But it is only half-applied to the narrator's context. With the fact
|
||||
`mara-knows / "knows where the key was found"` withdrawn by an explicit
|
||||
correction:
|
||||
|
||||
```
|
||||
state section contains the withdrawn fact: False ← correct
|
||||
full prompt contains the withdrawn assertion: True ← in the history replay
|
||||
any statement anywhere that it was corrected: False
|
||||
```
|
||||
|
||||
The state section drops it; the history section replays the original `add_fact`
|
||||
verbatim; and nothing in the prompt tells the model the assertion was
|
||||
withdrawn. The narrator is left holding the contradicted claim with no
|
||||
indication it is contradicted. **C04 PARTIAL.**
|
||||
|
||||
## K. Export / import
|
||||
|
||||
Round-tripping a campaign preserves the state document, campaign canon, save
|
||||
points, head depth (including an undone head, so I07 holds), every per-node
|
||||
snapshot (7 of 7), and correction *authority* — an imported corrected fact is
|
||||
still marked `manual_correction`.
|
||||
|
||||
It does not preserve the audit trail. `state_events` and `state_proposals` are
|
||||
not in the bundle, so an imported campaign reports **0 audit events**. The
|
||||
correction's effect survives; the record of who made it and why does not. This
|
||||
is the round-trip half of C04's "auditable" condition.
|
||||
|
||||
## L. Migration from pre-M5
|
||||
|
||||
A genuine pre-M5 database was constructed (M5 tables dropped, columns removed,
|
||||
`user_version` stamped back) and then migrated. Migration itself is clean: the
|
||||
campaign opens, all rows and save points survive, and new M5 turns play
|
||||
normally.
|
||||
|
||||
The defect is at the seam. Pre-M5 nodes have no snapshot, and
|
||||
`attempts.restore_state` treats a NULL snapshot as "leave the live state alone".
|
||||
So restoring to an old Save Point moves the transcript back without moving the
|
||||
state:
|
||||
|
||||
```
|
||||
restore to the pre-M5 Save Point at depth 2 -> head (1,2)
|
||||
state left standing describes depth 6 (the M5 turn's entities)
|
||||
```
|
||||
|
||||
The reader is at depth 2 and the state panel describes depth 6. §9 of the M5
|
||||
brief forbids exactly this.
|
||||
|
||||
## M. Performance
|
||||
|
||||
One regression, in the action-list read path.
|
||||
`paging.py:ACTION_LIST_COLUMNS` does not include `models.Action.state_changes`,
|
||||
so every row lazy-loads it individually:
|
||||
|
||||
```
|
||||
11 actions on the page -> 24 queries, 13 fetching state_changes one row at a time
|
||||
51 actions on the page -> 64 queries, 53 fetching state_changes one row at a time
|
||||
```
|
||||
|
||||
The comment directly above that list warns against this exact mistake for
|
||||
`world_delta`, in these words: *"Omitting it saves no bytes. It converts one
|
||||
bulk read into one lazy load per row."* The new column was added to the model
|
||||
without being added to the list.
|
||||
|
||||
## N. Frontend
|
||||
|
||||
`npm run lint` exits 0. Every warning it prints is pre-existing at `62a997f` in
|
||||
files M5 did not touch. `npm run build` succeeds. `docker build` succeeds and
|
||||
the image contains `backend/app/narrative/`; M5 added no dependency and needed
|
||||
no packaging change.
|
||||
|
||||
The stale-panel bug reported during implementation was fixed in `saveEdit` by
|
||||
bumping `stateKey`. That is the correct fix for that mutation, and the panel's
|
||||
key (`actions.length` + `stateKey`) does now cover every state-moving mutation:
|
||||
undo/redo, take switching, save-point restore, correction, delete. It is
|
||||
per-mutation rather than a single shared invalidation boundary, so a future
|
||||
mutation must remember to bump it — worth noting, but not a defect today.
|
||||
|
||||
## O. Real-browser verification
|
||||
|
||||
Firefox 154.0.1, headless, real backend, fresh database. **16 of 16 checks
|
||||
passed**: inspector starts empty; shows what a turn established; groups by
|
||||
category; shows possession with its owner; follows a later turn, Undo, Redo, and
|
||||
a Save Point restore; a manual correction lands, is marked as the reader's own,
|
||||
survives another turn, and is explained in the audit view; a narrator edit
|
||||
re-derives state; edited prose carries no protocol; no console errors.
|
||||
|
||||
That suite edits the **last** narrator turn, so it does not exercise the case in
|
||||
Finding 1. A second browser probe was written for that case, and it reproduces
|
||||
the defect visibly — see below.
|
||||
|
||||
## P. Realistic-model behaviour
|
||||
|
||||
Run against one small local model (`qwen2.5:3b-instruct`) on the trusted LAN. All
|
||||
3 otherwise-skipped tests pass. Over 6 turns:
|
||||
|
||||
```
|
||||
accepted 3 | partially_accepted 1 | unparseable 2
|
||||
events rejected 1 (unknown_reference)
|
||||
```
|
||||
|
||||
Two of six turns produced a state block the pipeline could not parse. The
|
||||
pipeline degrades correctly — the prose still lands, the state is left alone,
|
||||
and the proposal is recorded `unparseable` — which is why the tests pass. But a
|
||||
33% unparseable rate on a 3B model is a real datum for the prompt work ahead.
|
||||
|
||||
This is **one** model. It is not evidence about models in general, and no
|
||||
broader claim is made from it.
|
||||
|
||||
## Q. Test suite
|
||||
|
||||
```
|
||||
764 passed, 3 skipped, 1 warning (197s)
|
||||
```
|
||||
|
||||
All three skips are `test_narrative_realistic.py`, skipped for missing
|
||||
`AIDND_TEST_ENDPOINT` / `AIDND_TEST_MODEL`. With those set, all three pass (§P).
|
||||
There are no other skips and no xfails.
|
||||
|
||||
Rollback assertions were **moved, not deleted**. `test_worldstate_integration.py`
|
||||
went from 6 tests to 4 and was rewritten as a demotion test, with the removed
|
||||
tests and the reason for each recorded in the module docstring. Their subject
|
||||
matter is now covered by `test_narrative_state.py` (67 tests).
|
||||
|
||||
Two gaps:
|
||||
|
||||
- `test_state_revert.py` — the dedicated unit test for
|
||||
`attempts.restore_state` / `snapshot_outcome` — has **zero** references to
|
||||
`narrative_state`. It still tests only the legacy column. The NULL-snapshot
|
||||
rule that produces the migration defect in §L lives in exactly the code this
|
||||
file covers, and was not extended to the column M5 made load-bearing.
|
||||
- No test covers editing a turn that has later **visible** story. That is the
|
||||
hole Finding 1 sits in.
|
||||
|
||||
## R. Genre neutrality (J01–J03)
|
||||
|
||||
The backend is neutral. The new package is clean, and a science-fiction campaign
|
||||
plays through the same events with no fantasy vocabulary anywhere in the path.
|
||||
Remaining hits in the backend are either citations of AI Dungeon as the design
|
||||
being followed, the frozen wire-format string `ai-dnd-adventure-v2` (which must
|
||||
not change), or the demoted legacy world-state module's own examples.
|
||||
|
||||
The frontend is not. `frontend/src/pages/ScenarioEditor.jsx` is still routed at
|
||||
`scenarios/:id` and still tells the user, in user-facing prose, that the app
|
||||
"will track them each turn — HP, mana, a raised alarm, quest objectives", with a
|
||||
JSON placeholder of `player.hp`, `npcs.gwen.stats.trust`, and `milestones`. That
|
||||
is the surface ADR 010 replaced, still describing the replaced design to the
|
||||
user. J03 asks that genre be configuration; this screen hard-codes one genre's
|
||||
vocabulary as the explanation of how state works.
|
||||
|
||||
## S. Findings
|
||||
|
||||
### Finding 1 — Editing a turn with later visible story breaks the state/head invariant — **SERIOUS**
|
||||
|
||||
`STORY-BRANCH-SEMANTICS.md` §14A refuses an in-place edit only when descending
|
||||
story is **off screen**. An edit with a *visible* future is therefore permitted,
|
||||
and `_reevaluate_state()` in `routers/adventures/actions.py` then calls
|
||||
`narrative.store.set_current()` with the edited node's re-derived state — while
|
||||
the head stays at the tip.
|
||||
|
||||
The comment there asserts the case cannot arise: *"If the head sits further
|
||||
along, the refusal above already ran — this node has no off-screen future."*
|
||||
That is incorrect. The refusal covers off-screen futures only.
|
||||
|
||||
Reproduced in a real browser, on a four-turn campaign, by editing the first
|
||||
narrator turn through the UI's own ✎ control:
|
||||
|
||||
```
|
||||
state panel BEFORE : CHARACTERS Aldric, Mara | LOCATIONS the Crooked Lantern
|
||||
ITEMS the silver key | POSSESSIONS held by Mara
|
||||
FACTS Mara knows where the key was found
|
||||
OPEN STORY THREADS Reach the Old Abbey
|
||||
state panel AFTER : "Nothing established yet."
|
||||
transcript : all four turns still on screen
|
||||
head : still (1,7), the tip
|
||||
```
|
||||
|
||||
The database shows the invariant broken directly — the head's own snapshot is
|
||||
intact while the live column it is supposed to agree with is empty:
|
||||
|
||||
```
|
||||
live narrative_state entities 0 facts 0 threads 0
|
||||
snapshot at depth 7 (head) entities 4 facts 1 threads 1
|
||||
snapshots at depths 2..6 entities 4 ← stale: describe prose that no longer exists
|
||||
```
|
||||
|
||||
Three consequences: the reader sees a full transcript over an empty state panel;
|
||||
the next turn's prompt is built from the rewound state; and Undo/Redo restores
|
||||
the stale downstream snapshots, resurrecting state whose originating prose was
|
||||
edited away. An earlier API-level probe showed the milder form of the same bug —
|
||||
after editing a "red cloak" turn to "green", undoing back restored `cloak: red`
|
||||
while the visible prose said green, the prose/state disagreement §15 forbids.
|
||||
|
||||
**D10 is not satisfied.** Its M5 pass condition is *"downstream state is
|
||||
re-evaluated"*. Downstream state is not re-evaluated; live state is rewound and
|
||||
downstream snapshots are left stale. Separately, §15 lists five requirements for
|
||||
this workflow and only 1–3 are implemented: there is no new continuation and the
|
||||
original narration is overwritten in place, so *"old version/future remains
|
||||
retained"* also fails. The UI offers ✎ (edits in place, no fork) and ⑂ (forks,
|
||||
but regenerates rather than using the corrected text) — **no available path
|
||||
satisfies §15 fully.**
|
||||
|
||||
### Finding 2 — N+1 on the action list — **MODERATE**
|
||||
|
||||
`state_changes` is missing from `ACTION_LIST_COLUMNS`; 51 rows cost 53 extra
|
||||
queries. Details and the warning comment that predicted it: §M.
|
||||
|
||||
### Finding 3 — Restoring to a pre-M5 node leaves M5 state standing — **MODERATE**
|
||||
|
||||
NULL snapshot is treated as "leave the live state alone", so head and state
|
||||
disagree at any migrated position. Details: §L. Same broken invariant as
|
||||
Finding 1, different cause.
|
||||
|
||||
### Finding 4 — A withdrawn fact survives in the prompt's history replay — **MODERATE**
|
||||
|
||||
The state section drops it, the history replay keeps it verbatim, and nothing
|
||||
says it was corrected. Details: §I, §J.
|
||||
|
||||
### Finding 5 — Export/import loses the audit trail — **MINOR**
|
||||
|
||||
`state_events` and `state_proposals` are not bundled; an imported campaign shows
|
||||
0 audit events. Effects survive, records do not. Details: §K.
|
||||
|
||||
### Finding 6 — Extraction strips legitimate prose — **MINOR**
|
||||
|
||||
The fence stripper removes content it should leave alone:
|
||||
|
||||
```
|
||||
CUT a bracketed aside that merely mentions the state block
|
||||
CUT a story containing a legitimate ```json fence
|
||||
in : 'She typed it out:\n```json\n{"name": "Mara"}\n```\nThen closed the terminal.'
|
||||
out: 'She typed it out:\n\nThen closed the terminal.'
|
||||
KEPT a story containing a ```python fence
|
||||
```
|
||||
|
||||
A story about programmers loses its code. The `python` fence surviving shows the
|
||||
rule is specifically over-broad on `json`.
|
||||
|
||||
### Finding 7 — The RPG schema editor still describes the replaced design — **MINOR**
|
||||
|
||||
Details: §R.
|
||||
|
||||
### Finding 8 — Snapshot/restore unit tests were not extended to the new column — **MINOR**
|
||||
|
||||
Details: §Q.
|
||||
|
||||
## T. Acceptance criteria
|
||||
|
||||
| ID | Criterion | Result | Basis |
|
||||
|----|-----------|--------|-------|
|
||||
| C01 | Campaign canon preserved | PASS | canon survives turns, restore, and round-trip |
|
||||
| C02 | Possession state | PASS | `set_possession` moves ownership; shown with owner in UI |
|
||||
| C03 | Character knowledge not invented | PASS | `knows()` gated on recorded facts; unknown refs rejected |
|
||||
| C04 | Manual state correction | **PARTIAL** | reflected in state and auditable live; but the withdrawn fact survives in the history replay (F4) and the audit is lost on import (F5) |
|
||||
| C06 | Structured state matches accepted narration | PASS | absolute events only, no delta/absolute ambiguity; malformed proposals rejected, recorded, prose still lands |
|
||||
| D01 | Undo one turn | PASS | snapshot restore, verified in browser and API |
|
||||
| D04 | Redo | PASS | as above |
|
||||
| D05 | Redo invalidated by new continuation | PASS | abandoned line isolated (E01/E04 probes) |
|
||||
| D06 | Retry narrator response | PASS | unchanged from M3/M4, re-run green |
|
||||
| D07 | Select prior retry take | PASS | take switch restores that take's state |
|
||||
| D08 | Retry does not delete prior take | PASS | suite + browser |
|
||||
| D09 | Edit earlier user input | PASS | within the same mechanism as D10; no visible-future case in the fixtures |
|
||||
| D10 | Edit narrator output | **FAIL** | M5's own pass condition — downstream state re-evaluation — is not met; state is rewound, downstream snapshots left stale (F1); retention condition also unmet |
|
||||
| D11 | Named checkpoint | PASS | M4 machinery, re-verified in browser |
|
||||
| D12 | Restore checkpoint | PASS | state moves with the restore |
|
||||
| D13 | Restore does not delete later history | PASS | later rows retained and reachable |
|
||||
| D14 | Delete checkpoint | PASS | suite |
|
||||
| E01 | Abandoned future cannot affect active state | PASS | probe: no leak into state or prompt |
|
||||
| E04 | Scene state is lineage-safe | PASS | same probe |
|
||||
| H05 | Invalid state event rejected | PASS | hostile payloads produced no filesystem effect and no accepted events |
|
||||
| I01 | Export campaign | PASS | bundle carries state, canon, checkpoints, head |
|
||||
| I02 | Import exported campaign | PASS | 201, state and authority intact |
|
||||
| I03 | Branch/disposable history export | PASS | snapshots 7 of 7 |
|
||||
| I04 | Checkpoint export | PASS | save points round-trip |
|
||||
| I07 | Export/import preserves an undone active head | PASS | `can_redo` preserved in the copy |
|
||||
| J01 | Science-fiction campaign | PASS | plays through the same events, no genre coupling |
|
||||
| J02 | Generic entity support | PASS | `entity_type` is free-form |
|
||||
| J03 | Genre profiles are configuration | **PARTIAL** | backend neutral; the scenario editor still hard-codes HP/mana as the explanation of state (F7) |
|
||||
| L01 | Atomic turn commit | PASS | induced storage failure, not mocks — §H |
|
||||
| L02 | State reconstruction | PASS for undo/redo; breaks after an edit (F1) |
|
||||
| L03 | Checkpoint reconstruction after restart | PASS | M4's real-process-boundary test reads the M5 state document |
|
||||
|
||||
## U. Documentation
|
||||
|
||||
`planning/` was updated in step with the code, and `DATA-MODEL.md` §17 correctly
|
||||
describes the hybrid that was actually built. Two corrections are needed:
|
||||
|
||||
- `STORY-BRANCH-SEMANTICS.md` §14A should no longer say the refusal is "replaced
|
||||
by" M5's re-evaluation. It was not replaced; it was retained for off-screen
|
||||
futures, and the visible-future case it does not cover is unhandled.
|
||||
- The incorrect comment in `_reevaluate_state()` quoted in Finding 1 should be
|
||||
removed rather than reworded — it asserts an invariant the code does not hold.
|
||||
|
||||
## V. Planning-document recommendations
|
||||
|
||||
1. **The state-document shape deserves ratification.** ADR 010 settled the
|
||||
event protocol; it did not settle the document those events write into
|
||||
(`entities` / `facts` / `possessions` / `threads` / `scene`, each with
|
||||
authority and provenance). That shape is now load-bearing for the prompt, the
|
||||
UI, export, and migration. It should be written down as an ADR rather than
|
||||
left as an implementation detail of `narrative/model.py`.
|
||||
2. **§15 of the M5 brief needs re-specifying before it can be implemented.** It
|
||||
requires a corrected narrator turn to become authoritative *and* the original
|
||||
to be retained. Retaining the original means forking; the current ✎ does not
|
||||
fork and the current ⑂ does not use the typed text. The missing operation —
|
||||
"fork here, using this exact text, and re-derive forward" — should be
|
||||
specified explicitly.
|
||||
3. **D10 should record its remaining scope**, the way it already records its M3
|
||||
and M5 split, rather than being left looking complete.
|
||||
|
||||
## W. M6 readiness
|
||||
|
||||
M5's core is sound: the protocol is unambiguous, the security boundary holds,
|
||||
restore is snapshot-based and does not replay, lineage isolation is intact, and
|
||||
atomicity survives an induced storage failure. That is the hard part, and it
|
||||
works.
|
||||
|
||||
M6 should not start on top of Finding 1. The broken invariant is
|
||||
`transcript position == head == authoritative state`, and every later feature
|
||||
that reads state at a position inherits it. Findings 2, 3, and 4 are each
|
||||
contained enough to fix alongside it. Findings 5–8 can ride along or be filed.
|
||||
|
||||
Recommended sequence: fix Findings 1 and 3 together, since both are the same
|
||||
invariant reached by different routes; then 2 and 4; then close out M5.
|
||||
|
||||
---
|
||||
|
||||
## Evidence appendix
|
||||
|
||||
| Check | Command / harness | Result |
|
||||
|---|---|---|
|
||||
| Backend suite | `.venv/bin/python -m pytest tests/ -q` | 764 passed, 3 skipped |
|
||||
| Realistic model | same, with endpoint and model set | 3 passed (674s) |
|
||||
| Frontend lint | `npm run lint` | exit 0, warnings pre-existing |
|
||||
| Frontend build | `npm run build` | success |
|
||||
| Container | `docker build` | success; image contains `app/narrative/` |
|
||||
| Browser | Firefox 154.0.1 headless over WebDriver | 16/16 |
|
||||
| Browser (edit case) | second probe, first narrator turn | defect reproduced |
|
||||
| Atomicity | `state_events` renamed away mid-turn | no half-commit |
|
||||
| No-replay | `state_events` renamed away, then undo/redo | both 200 |
|
||||
| Hostile events | 5 payloads through the real turn path | 0 accepted, no filesystem effect |
|
||||
| N+1 | query counter over the action list | 51 rows -> 53 extra queries |
|
||||
| Migration | pre-M5 DB rebuilt, stamped, migrated | restore leaves state stale |
|
||||
| Export/import | real bundle round-trip | state yes, audit no |
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# ADDENDUM — M5 Corrective Pass
|
||||
|
||||
**Date:** 2026-09-04
|
||||
**Branch:** `m5-narrative-state` **Base commit:** `62a997f`
|
||||
**Status of the review above:** unchanged. Nothing in it has been edited or
|
||||
withdrawn. This addendum records what was done about it.
|
||||
|
||||
**Recommendation:** M5 CORRECTED — READY FOR CLOSEOUT / M6
|
||||
|
||||
## A. Repository and staging state
|
||||
|
||||
Recorded before any change was made:
|
||||
|
||||
```
|
||||
branch m5-narrative-state
|
||||
HEAD 62a997f364e5387e4cc1dcc20f5a6618dee5f267
|
||||
staged 45 files changed, 4979 insertions(+), 364 deletions(-)
|
||||
12 added, 33 modified, nothing unstaged, nothing untracked
|
||||
```
|
||||
|
||||
The staged M5 implementation was never reset, discarded, squashed or recreated.
|
||||
The corrective work is added on top of it, and the whole is now staged together:
|
||||
|
||||
```
|
||||
staged 57 files changed, 6809 insertions(+), 487 deletions(-)
|
||||
15 added, 41 modified, 1 renamed (the M4 report, into planning/archive)
|
||||
nothing unstaged, nothing untracked
|
||||
```
|
||||
|
||||
LICENSE is untouched, the AI-DnD base commit `d72f7c1b` is still an ancestor of
|
||||
HEAD, and no database, secret, token or browser profile is staged. No commit was
|
||||
created: this repository's commits are signed by its owner, so the message is
|
||||
prepared at `.git/M5_CORRECTIVE_MSG` instead (§P).
|
||||
|
||||
## B. Disposition of Findings 1–8
|
||||
|
||||
| # | Finding | Disposition |
|
||||
|---|---------|-------------|
|
||||
| 1 | Narrator edit breaks the state/head invariant (**serious**) | **Fixed.** Narrator editing rebuilt on §§14-15 fork semantics. §C, §D, §E. |
|
||||
| 2 | N+1 on the action list | **Fixed.** `state_changes` added to the bulk read; 51 rows now cost a constant query count. §G. |
|
||||
| 3 | Restoring to a pre-M5 node leaves M5 state standing | **Fixed.** Migration 88 backfills; a missing snapshot restores the empty document. §F. |
|
||||
| 4 | A withdrawn fact survives in the prompt's history replay | **Fixed.** History carries prose only; withdrawals are named explicitly. §H. |
|
||||
| 5 | Export/import loses the audit trail | **Deferred to M9**, deliberately, and recorded in C04 and BUILD-MILESTONES. §K. |
|
||||
| 6 | Extraction strips legitimate prose | **Fixed**, and a second defect found while fixing it. §I. |
|
||||
| 7 | Scenario editor describes the replaced design | **Copy corrected; screen deferred to M8.** §K. |
|
||||
| 8 | Snapshot/restore tests never covered the new column | **Fixed.** `test_state_revert.py` now covers it first-class. §J. |
|
||||
|
||||
## C. The narrator-edit implementation
|
||||
|
||||
The operation is no longer a write to the row being corrected. Nothing on the
|
||||
line being left is written to at all.
|
||||
|
||||
```text
|
||||
before after
|
||||
|
||||
parent parent
|
||||
└── original narrator ├── original narrator ──> old future [retained]
|
||||
└── old future └── corrected narrator [active]
|
||||
```
|
||||
|
||||
`_edit_narration` in `backend/app/routers/adventures/actions.py` maps one step
|
||||
to each clause of §15:
|
||||
|
||||
1. **Return to the state before the narration** — the preceding node's
|
||||
`narrative_state_after`, one row read, not a replay.
|
||||
2. **Treat the edited text as the accepted output** — stored verbatim with only
|
||||
the protocol block stripped. No model is called.
|
||||
3. **Re-evaluate the implied state** — the same extraction, allowlist, schema,
|
||||
reference and canon validation a generated turn faces.
|
||||
4. **Create a new active continuation** — a new node, and the head on it.
|
||||
5. **Retain the original narration and its future** — untouched.
|
||||
|
||||
Two shapes, chosen by whether anything was written after the turn:
|
||||
|
||||
- **At the tip:** the turn's attempts are still leaves, so the correction joins
|
||||
them as another take (`attempts.hand_over_the_prompt` + `attempts.add_attempt`)
|
||||
and the original stays beside it in the pager. No branch, and the head does
|
||||
not move.
|
||||
- **With story below it**, visible or not: `lineage.branch_of` →
|
||||
`tree.branch_at(depth - 1)` → `head.mark_superseded` → `tree.place_action`.
|
||||
The departed line keeps its node, its future and its live flag.
|
||||
|
||||
**No new history mechanism was introduced.** This is the ⑂ path already in
|
||||
`takes.py` with the reader's text in place of a generated reply, using the same
|
||||
`tree`, `head`, `attempts` and `lineage` primitives M3 and M4 provide.
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- The §14A refusal is **gone for narrator turns** — the case it refused is now
|
||||
handled rather than blocked, because a fork writes nothing to the off-screen
|
||||
line. It remains for a player's own input (§13), which M5 did not change.
|
||||
- Editing a take the story is **not** telling stays a plain in-place edit. Such
|
||||
a take has no continuation of its own, so correcting its words contradicts
|
||||
nothing. This preserved two existing behaviours the first draft of the fix had
|
||||
broken.
|
||||
|
||||
The frontend follows: `saveEdit` detects that the server answered with a
|
||||
different node and re-reads the window, because the shape of the story changed
|
||||
and only the server can say what the transcript is now.
|
||||
|
||||
## D. How retention was proven
|
||||
|
||||
Not by inspection — by identity. Every test below captures the original row's id
|
||||
and text and the ids of every row below it *before* the edit, and asserts they
|
||||
are all still present and unchanged afterwards.
|
||||
|
||||
- `test_editing_a_narrator_turn_with_visible_descendants_forks`
|
||||
- `test_the_old_narration_and_its_future_leave_the_active_transcript`
|
||||
- `test_editing_a_narrator_turn_with_an_undone_future_keeps_it`
|
||||
- `test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line`
|
||||
- `test_an_edit_is_safe_when_a_future_is_off_screen`
|
||||
- `test_editing_the_latest_narrator_turn_still_works` (the original take retained
|
||||
and no longer live, with no branch created)
|
||||
|
||||
In the browser, the same thing read straight out of SQLite: the original
|
||||
narrator row still holds its own words, every row of the old future still
|
||||
exists, and the correction is a different node id on a different branch id.
|
||||
|
||||
## E. How live-state/head consistency was proven
|
||||
|
||||
A helper asserts the invariant directly against the database rather than through
|
||||
the API, resolving the head node **through the lineage** — after a fork the head
|
||||
branch owns one node and inherits the rest of the path:
|
||||
|
||||
```python
|
||||
node = head.node_at(db, adventure, adventure.head_depth)
|
||||
return normalize(adventure.narrative_state) == normalize(node.narrative_state_after)
|
||||
```
|
||||
|
||||
It is asserted after every narrator edit, and at **every position visited** while
|
||||
undoing and redoing across an edit
|
||||
(`test_undo_and_redo_after_an_edit_stay_on_the_corrected_lineage`), which is
|
||||
where the review found the worst symptom — Undo restoring a snapshot from a line
|
||||
the reader was no longer on, so the prose said green and the state said red.
|
||||
That test additionally asserts that no attribute from the abandoned line ever
|
||||
reappears.
|
||||
|
||||
The same equality is checked in the browser run (check 8) and across Undo/Redo
|
||||
there (check 9).
|
||||
|
||||
## F. How pre-M5 positions are handled
|
||||
|
||||
Two halves, so that the stored data is explicit rather than relying on a
|
||||
fallback:
|
||||
|
||||
- **Migration 88** — no DDL, a data pass. `_backfill_narrative_snapshots` writes
|
||||
the empty narrative document onto every action whose `narrative_state_after`
|
||||
is NULL. One statement, no row loop: the document is identical for every row,
|
||||
so it is encoded once with the same `compression.pack` and `narrative.model`
|
||||
the runtime uses, and bound as a single parameter.
|
||||
- **`attempts.restore_state`** — a missing narrative snapshot now restores the
|
||||
empty document. This covers a node arriving from an older export, which the
|
||||
migration never sees.
|
||||
|
||||
The legacy RPG column deliberately keeps the opposite rule: a NULL
|
||||
`world_state_after` is still left alone, because nothing consults those numbers
|
||||
and blanking a running campaign's would help no one. The difference is now
|
||||
documented in the function rather than implicit.
|
||||
|
||||
The empty document is the honest answer. A pre-M5 position established nothing
|
||||
in the narrative-state system because that system did not exist yet; retaining
|
||||
another position's state is a claim about a story that had not been told.
|
||||
|
||||
`backend/tests/test_pre_m5_compatibility.py` builds a genuine pre-M5 database
|
||||
(M5 tables dropped, columns removed, `user_version` rewound to 80), migrates it,
|
||||
and runs the six required steps:
|
||||
|
||||
```
|
||||
the migration backfills every existing action PASS
|
||||
the migrated campaign opens and keeps its history PASS
|
||||
restoring a pre-M5 Save Point leaves no later state standing PASS
|
||||
undo/redo across the pre-M5 boundary stay coherent PASS
|
||||
a pre-M5 campaign can be continued normally PASS
|
||||
```
|
||||
|
||||
Restoring the old Save Point now yields `head depth 2` with an empty document
|
||||
and `live state == head snapshot`; redoing forward brings the M5 state back with
|
||||
the position it belongs to. State restoration remains a bounded row read — no
|
||||
head movement was turned into replay-from-root.
|
||||
|
||||
## G. Action-list query counts
|
||||
|
||||
Measured with a statement counter over the real endpoint, before and after:
|
||||
|
||||
| page | before | after |
|
||||
|------|--------|-------|
|
||||
| 11 actions | 24 queries, 13 fetching `state_changes` one row at a time | **13 queries** |
|
||||
| 51 actions | 64 queries, 53 fetching `state_changes` one row at a time | **13 queries** |
|
||||
|
||||
Constant with page size. The fix is one line: `models.Action.state_changes`
|
||||
joins `ACTION_LIST_COLUMNS`, which is what `models.py` already said it was for
|
||||
("`world_delta` has an M5 counterpart in `state_changes` for the bulk read").
|
||||
It is a small per-turn column of the same order as `world_delta`, not the
|
||||
deferred snapshot — no large unrelated column was pulled in, and
|
||||
`narrative_state_after` is still asserted *not* to be fetched in bulk.
|
||||
|
||||
`test_the_action_list_does_not_cost_a_query_per_action` asserts by measurement
|
||||
rather than by inspecting the column tuple, so a future column consumed during
|
||||
serialization is caught the same way. It was confirmed to fail without the fix
|
||||
(58 SELECTs for 52 rows) and pass with it.
|
||||
|
||||
## H. Manual corrections and the narrator's prompt
|
||||
|
||||
Two changes, neither of which touches the stored historical records:
|
||||
|
||||
- **Replayed history is prose only.** `_history_text` no longer reconstructs the
|
||||
protocol block into past turns. That reconstruction put a second, older
|
||||
account of the world into the same prompt as the authoritative one with
|
||||
nothing marking which governed, and handed back a fact the reader had
|
||||
explicitly withdrawn as an accepted event. The format instruction survives in
|
||||
`EMIT_RULE` (with a worked example) and `EMIT_REMINDER` (placed last).
|
||||
- **A withdrawn fact is named.** `render.for_prompt` adds a section after the
|
||||
facts that stand:
|
||||
|
||||
```text
|
||||
No longer true — do not treat these as established:
|
||||
Mara knows where the key was found — Mara never learned where the silver key was found.
|
||||
```
|
||||
|
||||
Silence was the problem: dropping the fact left the narration that first
|
||||
asserted it as the only account in the prompt, and prose reads as current
|
||||
truth.
|
||||
|
||||
Evidence, from the review's own Mara example run through the real turn pipeline:
|
||||
|
||||
```
|
||||
history section contains "```state": no
|
||||
contains "add_fact": no
|
||||
contains "mara-knows": no
|
||||
state section under "Established": the withdrawn fact is absent
|
||||
under "No longer true": named, with the reader's reason
|
||||
```
|
||||
|
||||
Nothing was deleted or rewritten: the invalidated fact keeps its status, reason
|
||||
and provenance in the document, and the `state_events` audit is untouched.
|
||||
|
||||
## I. Fence-stripping behaviour
|
||||
|
||||
The extractor now removes the application's own protocol payload and nothing
|
||||
else. `state` is our label and is taken unconditionally; `json` and unlabelled
|
||||
fences are taken only when their contents are this protocol — judged both by
|
||||
parsing into a proposal *and* by plainly reading as one.
|
||||
|
||||
| input | result |
|
||||
|---|---|
|
||||
| ```` ```state ```` block | stripped |
|
||||
| ```` ```state ```` block that does not parse | stripped, raw kept for the audit |
|
||||
| ```` ```json ```` containing `{"name": "Mara"}` | **kept** — it is the story |
|
||||
| ```` ```json ```` containing a real proposal | stripped |
|
||||
| ```` ```json ```` containing a *malformed* proposal | stripped |
|
||||
| ```` ```python ```` block | kept |
|
||||
| unlabelled fence with non-proposal JSON | kept |
|
||||
| dangling ```` ```json ```` that is story | kept |
|
||||
| dangling ```` ```json ```` that is a truncated proposal | stripped |
|
||||
| trailing bracketed aside mentioning "state block" | kept |
|
||||
| the prompt's own reminder, parroted back | stripped |
|
||||
|
||||
The malformed-proposal row is a defect the corrective pass introduced and then
|
||||
caught: narrowing the rule to "must parse" meant a small model that mangled its
|
||||
own JSON had the wreckage shown to the reader. **The realistic-model run found
|
||||
it, not the unit tests** — a reminder of why that run exists. The rule became
|
||||
"parses as a proposal, or plainly reads as protocol", and
|
||||
`test_a_malformed_proposal_in_a_json_fence_never_reaches_the_reader` pins it.
|
||||
|
||||
## J. Test results
|
||||
|
||||
```
|
||||
794 passed, 3 skipped, 1 warning (backend, 206s)
|
||||
```
|
||||
|
||||
Up from 764 passed at review time — 30 net new tests. The 3 skips are the
|
||||
realistic-model tests, skipped for missing `AIDND_TEST_ENDPOINT` /
|
||||
`AIDND_TEST_MODEL`; with those set they run (§L).
|
||||
|
||||
New and rewritten:
|
||||
|
||||
- `test_narrative_state.py` — seven D10 regressions, two correction-to-prompt
|
||||
regressions, nine fence-safety tests.
|
||||
- `test_pre_m5_compatibility.py` — new module, five migration regressions.
|
||||
- `test_state_revert.py` — seven new tests covering `narrative_state`
|
||||
first-class (Finding 8): the snapshot is recorded, deep-copied both ways, and
|
||||
written empty when there is none; restore puts it back, does not alias it, and
|
||||
restores the empty document when the node has no snapshot, while the legacy
|
||||
column keeps the opposite rule.
|
||||
- `test_egress.py` — the query-count regression and a guard that
|
||||
`narrative_state_after` stays out of the bulk read.
|
||||
- `test_head_cursor.py`, `test_change_visibility.py`, `test_retry_variants.py` —
|
||||
four tests rewritten. Each asserted behaviour this pass deliberately replaces
|
||||
(the §14A refusal for narrator turns; the protocol replay; edit-as-rewrite).
|
||||
None was deleted: each now asserts the stronger property that replaced it.
|
||||
|
||||
Targeted runs, all green: narrator edit with visible descendants; latest-turn
|
||||
edit; retained original future; live-state == head-snapshot; pre-M5 Save Point
|
||||
restore; Undo/Redo across old and new positions; manual correction → next
|
||||
prompt; fence stripping; action-list query count; atomic state commit; lineage
|
||||
isolation.
|
||||
|
||||
## K. Deferred, deliberately
|
||||
|
||||
- **M9 — export/recovery (Finding 5).** `state_events` / `state_proposals` are
|
||||
not carried in a bundle, so an imported campaign keeps a correction's effect
|
||||
(`manual_correction` authority survives) but reports zero audit events. C04
|
||||
therefore passes for a live campaign and not across a round trip; this is
|
||||
written into C04's result and into BUILD-MILESTONES rather than left implicit.
|
||||
Not fixed here: bundle format work is M9's, and the corrective gate did not
|
||||
require it.
|
||||
- **M8 — browser UX (Finding 7).** The scenario editor still exposes the legacy
|
||||
RPG stat schema. Its copy claimed the app "will track them each turn — HP,
|
||||
mana, a raised alarm, quest objectives", which M5 made false; that sentence is
|
||||
corrected to say what is now true. The screen itself is M8's to redesign.
|
||||
- **§13 — editing player input.** Still an in-place edit guarded by the
|
||||
off-screen refusal. Bringing it onto the §§14-15 footing was outside M5's
|
||||
scope; recorded in §14A and BUILD-MILESTONES.
|
||||
- **A player's own input may still contain a protocol fence**, since extraction
|
||||
applies to narrator output. Observed while building the browser harness, not a
|
||||
review finding, and not acted on here.
|
||||
|
||||
## L. Realistic-model results
|
||||
|
||||
Run against the same single small local model the review used, on the trusted
|
||||
LAN. All 3 otherwise-skipped tests pass (805s).
|
||||
|
||||
```
|
||||
review run corrective run
|
||||
turns 6 6
|
||||
accepted 3 6
|
||||
partially_accepted 1 0
|
||||
unparseable 2 0
|
||||
events rejected 1 (unknown_ref) 0
|
||||
assembled prompt 9382 chars 9922 chars
|
||||
```
|
||||
|
||||
**This is one stochastic run of six turns against one 3B model, and no causal
|
||||
claim is made from it.** The prompt did change materially — replayed history no
|
||||
longer carries protocol blocks, and withdrawn facts are now stated — so an
|
||||
effect is plausible in either direction, but six turns cannot separate that from
|
||||
run-to-run variance. What the run does establish is that the corrective changes
|
||||
did not degrade emission against a real model, and that the pipeline still
|
||||
degrades safely: no event was rejected and no block was unparseable.
|
||||
|
||||
The run also earned its keep by catching a real defect the unit tests missed —
|
||||
the malformed-proposal leak described in §I.
|
||||
|
||||
## M. Frontend, container
|
||||
|
||||
```
|
||||
npm run lint exit 0 (7 warnings, all pre-existing at 62a997f, in files M5 did not touch)
|
||||
npm run build success
|
||||
docker build success
|
||||
```
|
||||
|
||||
## N. Real-browser results
|
||||
|
||||
Firefox 154.0.1, headless, over WebDriver, against the real built frontend
|
||||
served by the real backend on a fresh database.
|
||||
|
||||
**The standard M5 checks: 16/16 pass** — inspector empty then populated, grouped
|
||||
by category, possession with owner, a later turn moving it, Undo, Redo, Save
|
||||
Point restore, a manual correction landing and marked as the reader's own and
|
||||
surviving another turn, the audit view, a narrator edit re-deriving state, no
|
||||
protocol in the prose, no console errors.
|
||||
|
||||
**The previously failing case: 16/16 pass.** Four turns establishing state, then
|
||||
the *earliest* narrator turn corrected through the UI's own ✎ control with all
|
||||
later story on screen — the exact case the review reproduced as broken:
|
||||
|
||||
```
|
||||
1-2 several turns established several facts PASS
|
||||
3 an Edit control on the earliest narrator turn PASS
|
||||
5 the corrected prose is what the story now tells PASS
|
||||
5b the original narration is off the active transcript PASS
|
||||
5c the old future is off the active transcript too PASS
|
||||
6 the original narrator row still exists, with its own words PASS
|
||||
6b every row of the old future still exists PASS
|
||||
6c the correction is a different node on a different branch PASS
|
||||
7 the panel shows what the corrected turn established PASS
|
||||
7b nothing from the abandoned future is still shown PASS
|
||||
8 live narrative_state equals the active-head snapshot PASS
|
||||
9 Undo and Redo never resurrect the abandoned line's state PASS
|
||||
10 the next prompt carries the corrected narration PASS
|
||||
10b and not the abandoned future PASS
|
||||
10c and no protocol block in the replayed history PASS
|
||||
11 no console errors PASS
|
||||
```
|
||||
|
||||
No console errors in either run.
|
||||
|
||||
One harness correction worth recording, because the first run's failures were
|
||||
mine and not the product's: the probe initially picked the first Edit control on
|
||||
the page, which belongs to the opening *player* action. Editing a player turn
|
||||
correctly takes the unchanged in-place path, so nothing forked. Addressing the
|
||||
earliest **narrator** row — `.action:not(.player)` carrying the expected words —
|
||||
was the fix, and every check then passed.
|
||||
|
||||
## O. Acceptance status
|
||||
|
||||
| ID | Before | After | Basis |
|
||||
|----|--------|-------|-------|
|
||||
| **D10** | FAIL | **PASS** | All three pass conditions demonstrated: corrected narration authoritative, state re-evaluated, old version and future retained. §C, §D, §E, §N. |
|
||||
| **C04** | PARTIAL | **PASS for the live campaign** | The withdrawn fact no longer returns through history and the withdrawal is stated explicitly. Auditability across export/import remains open and is recorded as M9 debt. §H, §K. |
|
||||
| **L02** | PASS for undo/redo; broke after an edit | **PASS** | The invariant is asserted at every position visited while undoing and redoing across an edit, and after restoring to a migrated pre-M5 position. §E, §F. |
|
||||
| **J03** | PARTIAL | **PARTIAL, narrowed** | Backend unchanged and neutral. The false user-facing claim is corrected; the legacy schema screen itself is M8's. §K. |
|
||||
| L01, E01, E04, H05, C01–C03, C06, D01–D09, D11–D14, I01–I04, I07, J01–J02, L03 | PASS | **PASS** | Re-run green in the full suite. |
|
||||
|
||||
## P. Planning documents changed
|
||||
|
||||
- `STORY-BRANCH-SEMANTICS.md` — §§14-15 gain a "How this is built" section with
|
||||
the fork diagram, the two shapes, and the invariant. §14A is retitled
|
||||
*Superseded for Narrator Output*: it no longer says the finished behaviour is
|
||||
pending, and records why the refusal is gone for narrator turns and what still
|
||||
uses it.
|
||||
- `V1-ACCEPTANCE-TESTS.md` — D10's milestone-ownership note replaced by a PASS
|
||||
result naming the evidence for each of its three unchanged pass conditions.
|
||||
C04 gains a result recording what is fixed and what is deferred. **Neither
|
||||
criterion's pass conditions were altered.**
|
||||
- `planning/DECISIONS/013-authoritative-narrative-state-document.md` — new ADR
|
||||
recording the document shape, the authority/provenance fields, the
|
||||
events → document → snapshot pipeline, what each store is for, the invariant,
|
||||
and the consequences. It records what M5 built and invents nothing beyond it.
|
||||
- `BUILD-MILESTONES.md` — status line moved to M1-M5 complete with M6 next; the
|
||||
§14-15 note records that the corrective pass delivered it; a new "M5 — Outcome"
|
||||
section records what shipped and the debt assigned to M8, M9 and §13.
|
||||
- `README.md` — the play-loop feature bullet now says what a narrator correction
|
||||
does.
|
||||
- `planning/VERSION.md` — one line recording ADR 013.
|
||||
- `planning/reports/M4-IMPLEMENTATION-REPORT.md` → `planning/archive/milestone-reports/`
|
||||
(report rotation; a clean rename).
|
||||
|
||||
## Q. Verification summary
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Backend suite | **794 passed, 3 skipped** (206s) |
|
||||
| Realistic model, one small local model | **3 passed** (805s); 6/6 turns accepted, 0 unparseable |
|
||||
| Frontend lint | exit 0, warnings pre-existing |
|
||||
| Frontend build | success |
|
||||
| Docker build | success |
|
||||
| Browser — standard M5 checks | **16/16** |
|
||||
| Browser — the previously failing edit case | **16/16** |
|
||||
| Action-list queries, 51 rows | 64 → **13** |
|
||||
| Pre-M5 Save Point restore | head, transcript and state agree |
|
||||
|
||||
## R. Is M5 safe to close, and is M6 safe to begin?
|
||||
|
||||
**Yes to both.**
|
||||
|
||||
The invariant M6 would have inherited is now held everywhere it was broken, and
|
||||
held by assertion rather than by argument: after a narrator edit, at every
|
||||
position visited across Undo and Redo, and after restoring to a migrated pre-M5
|
||||
position. The two failures the review called the same invariant reached by
|
||||
different routes are fixed by the same rule rather than by two special cases.
|
||||
|
||||
Nothing deferred blocks M6. Export of the audit trail is M9's own subject
|
||||
matter; the scenario editor is an M8 screen; §13 is an existing, guarded
|
||||
behaviour that M5 did not change and M6 does not build on.
|
||||
|
||||
**M5 CORRECTED — READY FOR CLOSEOUT / M6**
|
||||
Reference in New Issue
Block a user