Compare commits

..
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 b7005e6fdd 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
2026-09-05 07:01:50 -04:00
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00
JesseMarkowitzandClaude Opus 5 279a871a77 Planning: add the M4 implementation review report and rotate M3's
Reporting pass only. No application code, no test, and no product
requirement changes.

Result: PASS WITH CORRECTIVE WORK REQUIRED.

M4's Definition of Done is met and demonstrated at the API level, including
across a real two-process restart. The load-bearing constraint holds under
inspection rather than assertion: the only head-field assignment M4 added
anywhere in the backend is one line in head.py, and an exhaustive grep of the
diff finds no second mechanism that forks, prunes memories, reconstructs
state, filters the transcript or recomputes Redo.

Three corrective items, all M4's own, none in the head model:

- GET /checkpoints is an N+1 fetching whole Action rows including prose --
  measured at 53 SELECTs for 25 Save Points against 4 for the branch panel,
  in a codebase that keeps test_egress.py for this exact class of mistake;
- deleting a branch silently deletes Save Points naming it, and the branch
  panel's confirmation does not say so. M4 added the consequence to an
  existing destructive action without updating its warning;
- the shipped D11/L03 tests restart a client, not a process, so the suite is
  weaker than the acceptance items it is named for. Both pass here only
  because the report re-ran them across a real process boundary by hand.

The browser smoke test is NOT PERFORMED, for M4 and still for M3. Firefox is
a snap that hangs past 90s on a trivial headless screenshot; there is no
Xvfb, no display, no driver library. Two consecutive milestones now carry an
unperformed browser requirement, which the report raises as a standing
acceptance risk rather than a defect in either milestone's code.

Evidence recorded: 680 backend tests pass (42 M4, 130 M3 invariants, 93
security/local-only), frontend lint and build clean, Docker build clean,
migration 80 verified against a representative pre-M4 database with both
cascades and zero possible orphans, and I04 verified through a real round
trip with branch ids remapped 1->3 and 2->4.

M4 is NOT accepted by this report, and M5 is NOT authorized. That decision
belongs to whoever reviews this.

Rotation: planning/reports/M3-IMPLEMENTATION-REPORT.md moves to
planning/archive/milestone-reports/ as a pure rename, contents unedited
(git reports 100% similarity, 0 insertions, 0 deletions). Six path
references in five active documents are updated because the path changed
and for no other reason -- no status claim, no wording change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 19:15:53 -04:00
JesseMarkowitzandClaude Opus 5 e08d49c3eb M4: add durable named Save Points
A Save Point is a name for a story position, and restoring one is head
movement. That is the whole architecture, and it is what ADR 012 and
BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored
(branch, depth) and made arriving at one a row lookup plus a state restore,
so a Save Point needs no restore machinery of its own.

What the user gets:

- Name the moment they are reading, keep playing, restart the app, and come
  back to it. Restoring moves the story back and deletes nothing: the later
  turns stay, Redo still walks forward into them, and writing something
  different is what starts a new line while the old one is kept.
- Rename, delete, and a list, in a Save Points panel beside the branch panel,
  with a Save Point button next to Undo and Redo. Both confirmations say what
  is *not* destroyed, because that is the part the screen cannot show.
- Save Points survive export and import.

What was deliberately not built:

- No second restore path. `head.move_to_node` is the only new movement: its
  depth half is M3's `head.move_to` unchanged, and its branch half is the
  single assignment `switch_branch` already makes. No head field is written
  in the checkpoint router, nothing reconstructs state, nothing prunes a
  memory, nothing copies or deletes a turn, and restore never forks — the
  first write below the restored head does, through `fork_if_behind_head`.
- No automatic cleanup. A Save Point behind the head, or naming a line the
  story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal
  is a cascade: deleting a branch takes its Save Points, as it takes its
  memories, because the story they named went with it.
- No new ADR. ADR 012 already decides the architecture, and a table is not a
  decision.

The one call the planning package did not already make: restore moves the
branch half of the head only when the coordinate is off the path being read.
Doing it unconditionally would quietly hand back an abandoned continuation
whenever a Save Point in a shared prefix was restored; never doing it would
make a Save Point on a departed line unrestorable, which contradicts §19.
TECHNICAL-DESIGN §8.8 records it.

Schema: a `checkpoints` table holding a name, an optional note and a
(branch, depth) coordinate — no copy of any story. `create_all` builds it as
it did `memories` and `branches`; migration 80 adds the index. No backfill,
because nobody had named a position before M4.

The coordinate is deliberately not an action id: one coordinate holds every
attempt at a turn and exactly one is live, so a coordinate follows a retry
where a row id would pin a take the story no longer tells.

Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering
D11-D14, I04, L03, E-series lineage and memory isolation after restore and
divergence, the edge cases, and an M3-database migration. One pre-existing
fixture in test_tree_migration.py needed `checkpoints` added to its drop
list — SQLite refuses to drop a table another table references.

Not verified: the browser. No session has had a usable one, so the Save
Point panel's DOM behaviour is unobserved — as M3's Redo control still is.
The twenty-step sequence was driven over HTTP against a live server with a
real process restart instead, and all seventeen checks pass. M4 is
implemented, not accepted: no review has been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 18:48:54 -04:00
JesseMarkowitzandClaude Opus 5 3c8e91f644 Docs: correct post-M3 status and Ollama configuration
Three stale claims found in active documentation after the post-M3
consolidation:

- BUILD-MILESTONES.md still opened "M1 and M2 complete; M3 next", which
  contradicted its own M3 "Status: COMPLETE" block, planning/README.md and
  VERSION.md. It now states M1, M2 and M3 complete and accepted, M4 next.
- DEVELOPMENT.md described the settings row as "endpoint, model and (unused)
  API key" and sent "api_key":"" in its curl example. M2 removed the field;
  schemas.SettingsUpdate has no api_key. The prose and the example now match
  the real request shape. No application code was changed.
- README.md listed LM Studio as a supported local endpoint. ADR 002 and ADR 011
  make Ollama the only v1 backend; LM Studio is a rejected alternative there.
  The row is removed and the surrounding wording now says Ollama is the
  supported backend, same-host is the default, trusted-LAN Ollama is supported,
  public/cloud is prohibited, and the OpenAI-compatible adapter is an
  implementation detail rather than a support promise. The claude_shim section
  stays, relabelled "(development only)".

Documentation only; no code, schema or test changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 16:39:29 -04:00
72 changed files with 12288 additions and 521 deletions
+36 -5
View File
@@ -91,15 +91,16 @@ decision that this project's threat model does not cover
## Pointing the storyteller at Ollama
The endpoint, model and (unused) API key are **runtime settings stored in the
database**, not environment variables. Set them on the app's Settings page, or
with one request:
The endpoint, the model and the generation parameters are **runtime settings
stored in the database**, not environment variables. There is no API key field:
M2 removed it along with the cloud providers, and Ollama does not use one. Set
them on the app's Settings page, or with one request:
```bash
curl -X PUT http://127.0.0.1:8000/api/settings \
-H 'Content-Type: application/json' \
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
"api_mode":"chat","api_key":"","max_output_tokens":200,
"api_mode":"chat","max_output_tokens":200,
"context_token_budget":4096}'
```
@@ -208,7 +209,7 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 638 tests
cd backend && .venv/bin/python -m pytest tests/ -q # 756 tests
cd frontend && npm run lint && npm run build
```
@@ -224,6 +225,36 @@ 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
take a Save Point with it. It also fails if listing Save Points goes back to one
query per Save Point, or starts fetching narration to render the list.
`test_process_restart.py` is the durability guard: it starts the application as a
real subprocess, kills it, and starts a second one against the same database. A
Save Point that survived only because a Python object was still alive would pass
an in-process test and fail a user's restart.
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
addresses widens, or if either place the rule is applied stops applying it —
it resolves hostnames through a stub, so it tests the policy rather than
+48 -22
View File
@@ -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`).
@@ -67,10 +73,22 @@ that isn't the live one starts a new branch.
displaced future stays on the line it was written for, and ordinary Redo stops offering it.
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
turn, one keystroke and one click from becoming a branch of its own.
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
restart the app, and come back to it. Restoring one moves the story back to
that moment and deletes nothing: the turns you wrote after it stay, Redo still
walks forward into them, and writing something different from the Save Point
is what starts a new line while the old one is kept. A Save Point is a name for
a position and holds no copy of the story, so restoring it is the same
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
`backend/app/head.py`). They last until *you* delete them: deleting one deletes
no story, and deleting a branch a Save Point is kept on is refused until you
remove the Save Point yourself, so nothing takes a named moment away behind
your back.
- **Import and export.** AI Dungeon-compatible scenario format; JSON for everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, the fork points, which branches the story has left behind, and the
position it is being read at — all of them chosen rather than computed, which is the rule for
what a bundle carries. A campaign exported after two Undos imports still undone, with its
every branch, every take, the fork points, which branches the story has left behind, the Save
Points and the position it is being read at — all of them chosen rather than computed, which is
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
merely because it has one. A campaign exported after two Undos imports still undone, with its
retained future intact, instead of silently reopening at its newest turn. Files that predate
the head position, and files saved in the old single-line format, still import.
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
@@ -88,8 +106,9 @@ that isn't the live one starts a new branch.
None yet. The inherited screenshots showed upstream's UI — a Scripts tab, Log in and Sign up,
a guest banner, scripting demo scenarios — none of which this fork has since M2, so they were
removed rather than left standing as a picture of a product that no longer exists. New ones
are taken when the browser smoke test M3 still owes is run.
removed rather than left standing as a picture of a product that no longer exists. The M4
closeout drove the real application in a real browser, so the screens exist and work; taking
presentable screenshots of them is a job for the UI pass in M8.
## Quick start
@@ -121,18 +140,23 @@ Open http://localhost:5173.
## Connect a model
Open **Settings** in the app and point it at a local Ollama-compatible endpoint:
Ollama is the inference backend v1 supports. Open **Settings** in the app and point it at one:
| Where the model runs | Endpoint URL | Notes |
| Where Ollama runs | Endpoint URL | Notes |
|---|---|---|
| Ollama, same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
| Ollama, a machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | see below |
| LM Studio, same machine | `http://localhost:1234/v1` | |
| The same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
| A machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | explicitly configured; see below |
Model name, generation parameters, and (optionally) summary and embedding models for the
Memory Bank are configured there too. No config files and no rebuild are needed. There is no
API key field, because there is nothing to authenticate to.
The adapter underneath speaks the OpenAI-compatible protocol, because that is what Ollama
serves. That is an implementation detail, not a promise of support for arbitrary local
servers that happen to speak the same protocol. Public and cloud inference endpoints are
prohibited outright — see `planning/DECISIONS/002-ollama-only-v1.md` and
`planning/DECISIONS/011-local-inference-endpoint-policy.md`.
### What the endpoint policy allows
The address is checked when you save it and again before every request. Only loopback and
@@ -146,7 +170,7 @@ machine serves HTTPS with a certificate from a CA you installed, it works: certi
verified against your operating system's trust store as well as the bundled one. Verification
itself is never relaxed, and there is no option to turn it off.
### Playing against a local shim
### Playing against a local shim (development only)
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint on `127.0.0.1:8787`
backed by a command-line tool, which is useful for testing the turn engine against a stronger
@@ -191,9 +215,11 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
├─ tree.py forking, promotion, and where a node is placed
├─ head.py the active head: where the story is read, and what moving it costs
├─ 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
@@ -205,7 +231,7 @@ development, Vite proxies `/api` to FastAPI.
## Tests
638 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
View File
@@ -38,6 +38,7 @@ from sqlalchemy.orm import Session, undefer
from . import models
from .context import lineage
from .narrative import model as narrative_model
# The slices of a context snapshot that belong to one attempt rather than to the
# turn. They are the world-state delta the attempt proposed and what the engine
@@ -45,7 +46,7 @@ from .context import lineage
# token accounting. Each attempt is its own API call, and a retry is the call
# most likely to read the prompt back out of cache. Everything else in a snapshot
# is the prompt, which is assembled once per turn.
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
# ------------------------------------------------------------------ reading
@@ -152,26 +153,67 @@ def preceding(
# ------------------------------------------------------------------ writing
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
"""Restores the world state that `node` left behind.
"""Restores the state that `node` left behind.
A NULL snapshot means leave the live state as it is, never reset it. Rows
written before SP4 that the migration could not derive an outcome for carry
NULLs, and overwriting a running adventure's state with an empty dict would
be worse than doing nothing.
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
the same at any distance: the destination node carries its own outcome, so
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
M5 changed what is restored, not how — the narrative state document takes
the place the RPG world state held, through the same single function.
The two columns follow **different** rules about a NULL, and the difference
is not an oversight.
For the narrative document, a NULL means *this position established
nothing*, and it is restored as the empty document. Leaving the live state
alone instead is what the M5 review caught (Finding 3): arriving at a
migrated pre-M5 node left a later position's entities, facts and threads
standing, so the transcript said depth 2 while the state described depth 6.
The invariant this module exists to hold is that the visible position, the
head and the authoritative state agree, and "keep whatever was there" cannot
hold it. An empty document at an old position is honest — the narrative
state system knew nothing then, because it did not exist — where retained
state from elsewhere is a claim about a story that had not been told yet.
Migration backfills those rows explicitly, so this fallback is the belt to
that pair of braces: it also covers a node arriving from an older export,
which the migration never sees.
For the legacy RPG world state a NULL still means leave it alone. Those rows
predate SP4, nothing consults the values to decide anything, and overwriting
a running adventure's numbers with an empty dict would be worse than doing
nothing.
"""
if node is None:
return
adventure.narrative_state = (
copy.deepcopy(node.narrative_state_after)
if isinstance(node.narrative_state_after, dict)
else narrative_model.empty()
)
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
# coherent with the position being read, so an old save is not left showing
# a future's values. Nothing consults them to decide anything.
if isinstance(node.world_state_after, dict):
adventure.world_state = copy.deepcopy(node.world_state_after)
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
"""Records on `node` the state of the adventure now that the node has played."""
"""Records on `node` the state of the adventure now that the node has played.
Every node, including a player's action that changed nothing. A position
without a snapshot is a position the head cannot be restored to, and the
head can rest on any node.
"""
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
# `state_after` held the scripting engine's shared state, which M2 removed.
# The column stays for schema compatibility and is written empty.
node.state_after = {}
node.world_state_after = copy.deepcopy(world)
narrative = adventure.narrative_state
node.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def roll_back_before(
+152
View File
@@ -60,6 +60,7 @@ from sqlalchemy.orm import Session, undefer
from . import attempts, models, schemas
from .context import cursors, lineage
from .narrative import model as narrative_model
FORMAT = "ai-dnd-adventure-v2"
LEGACY_FORMAT = "ai-dnd-adventure-v1"
@@ -121,6 +122,14 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"desc": adventure.persona_desc,
},
"worldState": adventure.world_state,
# M5. The authoritative narrative state, and the campaign's own rules.
# Both are decisions rather than derivations — the state is what the
# campaign established, and canon is what its owner wrote — so both go
# in the file by the rule at the top of this module. A bundle written
# before M5 has neither key and imports with an empty state, which is
# what such a campaign had.
"narrativeState": adventure.narrative_state,
"campaignCanon": adventure.campaign_canon,
"autoSummarize": adventure.auto_summarize,
"memoryBankEnabled": adventure.memory_bank_enabled,
# Write a root entry even for an adventure whose branch row was never
@@ -134,6 +143,16 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
"memories": [_exported_memory(m, local) for m in adventure.memories],
# M4. A Save Point is a decision — someone chose this position and gave
# it a name — so it goes in the file by the rule at the top of this
# module. Nothing here is derived: the coordinate is the one stored, not
# one recomputed from the rows, because the whole point of the pointer
# is that no amount of reading the turns can tell you which one somebody
# named. A bundle written before M4 has no key here and imports with no
# Save Points, which is what such a campaign had.
"checkpoints": [
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys,
"entry": c.entry, "notes": c.notes}
@@ -197,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
@@ -215,6 +242,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
}
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
"""Returns the campaign's Save Points in creation order.
Read with a query rather than through a relationship, for the reason
`models.Branch` declares none: a relationship on `Adventure` would be loaded
by anything that touches an adventure, and the export is the only thing in
the application that wants every Save Point at once.
"""
return (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.id)
.all()
)
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
return {
"name": checkpoint.name,
"note": checkpoint.note,
# The branch as a position in this file's list, like every other branch
# reference in the bundle. The depth is a coordinate along it and needs
# no translation.
"branch": _local(checkpoint.branch_id, local),
"depth": checkpoint.depth,
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
}
def _imported_persona(persona) -> dict:
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
@@ -280,6 +336,13 @@ def plan(bundle: dict, version: str) -> dict:
"branches": branches,
"nodes": nodes,
"memories": _planned_memories(bundle, len(branches)),
# M4. Empty for a version 1 bundle and for any version 2 bundle written
# before Save Points existed, which is the same answer: no one had named
# a position in those campaigns.
"checkpoints": (
_planned_checkpoints(bundle, len(branches), nodes)
if version == FORMAT else []
),
"head": head,
# None means the file does not say, which is every version 1 bundle and
# every version 2 bundle written before M3. `_point_the_head` derives it
@@ -449,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")),
@@ -525,6 +590,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
return out
def _planned_checkpoints(
bundle: dict, branches: int, nodes: list[dict]
) -> list[dict]:
"""Returns the file's Save Points, checked against the tree it also carries.
A Save Point whose coordinate names no turn in the file is dropped rather
than imported, and dropped rather than raising. The two halves of that are
each deliberate:
* Dropped, because an imported pointer to a position the imported story does
not contain is a Save Point that can only ever refuse to restore. It would
be a row that exists to disappoint.
* Not a 400, unlike the head depth. The head is a position the campaign is
read at, so a file that misplaces it opens the story in the wrong place
and every read is affected. A Save Point is a bookmark, and a bad one
spoils nothing else in the file — refusing to import a whole campaign
because one bookmark is wrong would lose the story to save the bookmark.
A name that is blank once trimmed is dropped for the same reason the create
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
"""
# Every coordinate the file writes, not only the ones it marks live.
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
# the file says, so a coordinate that exists is a coordinate that will
# resolve — and reading the flags here would drop a Save Point over a
# question the writer has already settled.
written = {(n["branch"], n["depth"]) for n in nodes}
raw = bundle.get("checkpoints")
out: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
if not name:
continue
depth = entry.get("depth")
if not _is_int(depth):
continue
branch = _as_index(entry.get("branch"), branches, default=None)
if branch is None or (branch, depth) not in written:
continue
out.append({
"name": name,
"note": str(entry.get("note") or ""),
"branch": branch,
"depth": depth,
"createdAt": _as_time(entry.get("createdAt")),
})
return out
def _planned_anchors(bundle: dict, branches: int) -> dict:
anchors = {}
for name in ("memory", "summary"):
@@ -550,6 +666,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
_write_nodes(db, adventure, story["nodes"], ids)
_write_memories(db, adventure, story["memories"], ids)
_point_the_head(adventure, story, ids)
_write_checkpoints(db, adventure, story["checkpoints"], ids)
_write_anchors(adventure, story, ids)
@@ -620,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"]
@@ -665,6 +784,32 @@ def _write_memories(
db.add(memory)
def _write_checkpoints(
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
) -> None:
"""Writes the Save Points, and moves nothing.
Note what this function does not touch. The head is pointed by
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
a Save Point must not disturb it — a campaign exported at turn 30 with a
Save Point at turn 12 opens at turn 30. The bundle records where the story
was being read and, separately, which positions someone named; restoring one
of them is a thing the user does afterwards, not a thing an import does for
them.
"""
for spec in specs:
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=spec["name"],
note=spec["note"],
branch_id=ids[spec["branch"]],
depth=spec["depth"],
)
if spec["createdAt"] is not None:
checkpoint.created_at = spec["createdAt"]
db.add(checkpoint)
def _point_the_head(
adventure: models.Adventure, story: dict, ids: list[int]
) -> None:
@@ -747,6 +892,13 @@ def materialize(
ai_instructions=str(payload.get("aiInstructions") or ""),
story_summary=str(payload.get("storySummary") or ""),
world_state=payload.get("worldState") or {},
# M5. Normalised on the way in, so a hand-edited or truncated state
# section costs the section rather than the campaign — the story is the
# valuable thing, and a malformed document should not refuse an import.
narrative_state=narrative_model.normalize(payload.get("narrativeState"))
if isinstance(payload.get("narrativeState"), dict) else None,
campaign_canon=payload.get("campaignCanon")
if isinstance(payload.get("campaignCanon"), dict) else None,
auto_summarize=bool(payload.get("autoSummarize", False)),
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
**_imported_persona(payload.get("persona")),
+86 -61
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
import tiktoken
from .. import models, worldstate
from .. import models, narrative, worldstate
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
@@ -88,7 +88,7 @@ class Section:
return count_tokens(self.text)
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
def length_hint(max_output_tokens: int) -> str:
"""Ask for a turn that fits inside the output cap, stated as a word budget.
Returns an empty string when the cap is too small to state usefully. The
@@ -98,12 +98,8 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
if words < MIN_LENGTH_HINT_WORDS:
return ""
tail = (
" Finish the narration and append the state block well inside the limit."
if has_ws
else " Bring the turn to a close well inside the limit rather than "
"stopping mid-sentence."
)
tail = " Finish the narration and append the state block well inside the limit."
# State the number as a ceiling, never as a budget. In measurements, the
# wording "keep this turn under about N words" read to the model as a target
# to fill. It raised the average from 174 words to 246 across five runs, and
@@ -166,30 +162,58 @@ def _script_memory(adventure: models.Adventure) -> dict:
def _history_text(action: models.Action) -> str:
"""Returns an AI turn as the model should see it in replayed history.
The result is the narration with its state block appended again,
reconstructed from the stored delta. The app strips that block before
storing and displaying the turn. Without this function, every past AI turn
would appear to have emitted no state, and the model would copy that pattern
and stop emitting state itself. Player turns and turns with no block pass
through unchanged.
Replayed history is **prose only**. The protocol block is not reconstructed
into it, and the M5 corrective pass is why (review Finding 4).
The block replays the changes the engine ACCEPTED, not the ones the model
sent. Replaying what was sent showed the model a refused change standing as
though it had been applied, while the live values in the same prompt
disagreed with it. Nothing marked which of the two was true, so the model
read its own refused change as correct and sent it again.
Replaying the block was meant to teach the model the output format by
example. What it actually did was put a second, older account of the world
into the same prompt as the authoritative one, with nothing marking which
governed. A fact the reader had explicitly withdrawn through a manual
correction was dropped from the state section and then handed straight back
in the history section, as an accepted event, phrased exactly as the model
had first asserted it. C04 requires a correction to reach the narrator's
context; a correction the next prompt contradicts has not reached it.
This function reads `world_delta` rather than `context_snapshot`. It runs
for every action in the replayed history, and `context_snapshot` is deferred
so that a turn never loads the prompt archive from the database.
Two other things were wrong with it. The blocks are implementation
metadata, not story, and every other consumer of stored text — memory,
summaries, export, the transcript — treats an action's text as prose. And a
turn's accepted events are a record of what was true *then*, which is
precisely what a later correction, retcon or invalidation revises.
The format instruction survives without the examples: `EMIT_RULE` carries a
worked example in the system block and `EMIT_REMINDER` repeats the demand
last, where recency is strongest.
"""
text = action.text
wd = action.world_delta if isinstance(action.world_delta, dict) else None
if wd:
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
if block:
text = f"{text}\n{block}"
return text
return action.text
def _canon_section(adventure: models.Adventure) -> str:
"""The campaign's own rules, rendered for the system block.
Canon is configuration (C01, J03): the campaign writes what is true and what
is forbidden, and both the prompt and the validator read the same field.
Putting it in the system block is what makes C01 a narration-time constraint
as well as a validation-time one — the model is told the rule rather than
only refused after breaking it.
"""
canon = adventure.campaign_canon
if not isinstance(canon, dict):
return ""
lines: list[str] = []
rules = canon.get("rules")
if isinstance(rules, list):
lines += [f"- {rule}" for rule in rules if isinstance(rule, str) and rule.strip()]
forbidden = canon.get("forbidden_status_changes")
if isinstance(forbidden, list):
for rule in forbidden:
if isinstance(rule, dict) and rule.get("from") and rule.get("to"):
lines.append(
f"- Nothing that is {rule['from']} can become {rule['to']}."
)
if not lines:
return ""
body = "\n".join(lines)
return f"Campaign canon (these are true and may not be contradicted):\n{body}"
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
@@ -259,11 +283,14 @@ def build_context(
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
has_ws = worldstate.has_schema(stat_schema)
persona_name = adventure.persona_name.strip()
if has_ws:
guide = worldstate.render_reference(stat_schema, persona_name)
if guide:
system_sections.append(Section("world_state_guide", guide))
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
# M5: the typed-event protocol replaces the delta rule for every campaign,
# with or without an inherited stat schema. State is no longer an opt-in
# RPG layer — a story has entities, places and possessions whatever genre it
# is, so the rule is unconditional.
system_sections.append(Section("state_rule", narrative.extract.EMIT_RULE))
canon_text = _canon_section(adventure)
if canon_text:
system_sections.append(Section("campaign_canon", canon_text))
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
system_sections.append(Section("script_context", script_mem["context"].strip()))
@@ -301,21 +328,20 @@ def build_context(
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
world_state_section = None
refusal_note = ""
if has_ws:
# One read serves both the in-scene NPCs and the refusal note below.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
persona_name,
)
if block:
world_state_section = Section("world_state", block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = worldstate.render_refusals(last_ai.world_delta)
# M5: the authoritative narrative state, as the model is shown it. Read from
# the campaign's live document, which head movement keeps pointed at the
# position being read — so an undone story is described by the state it had
# then, not by the state it reached later.
state_block = narrative.render.for_prompt(adventure.narrative_state)
if state_block:
world_state_section = Section("narrative_state", state_block)
# Corrections for the previous AI turn only. A refusal the model has
# already had one chance to fix is stale, and repeating it every turn
# would price a correction into the whole rest of the adventure.
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
if last_ai is not None:
refusal_note = narrative.extract.render_rejections(last_ai.state_rejections)
authors_note_text = adventure.authors_note.strip()
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
@@ -326,7 +352,7 @@ def build_context(
if isinstance(script_mem.get("frontMemory"), str):
front_memory = script_mem["frontMemory"].strip()
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
length_note = length_hint(settings.max_output_tokens)
# The live sections sit below the history, but they are still part of the
# prompt, so they still count against the budget. `world_lore` is the
@@ -341,7 +367,7 @@ def build_context(
+ count_tokens(authors_note)
+ count_tokens(front_memory)
+ count_tokens(length_note)
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
+ count_tokens(narrative.extract.EMIT_REMINDER)
+ count_tokens(refusal_note)
)
available = max(256, settings.context_token_budget - reserved)
@@ -386,7 +412,7 @@ def build_context(
for action in reversed(actions):
# Budget against the text as it appears in the prompt, which includes
# the state block when this adventure tracks world state.
rendered = _history_text(action) if has_ws else action.text
rendered = _history_text(action)
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
if spent + tokens > history_budget:
if not included_actions:
@@ -407,7 +433,7 @@ def build_context(
# ----- Assemble the story text, with the author's note near the end -----
# Append each AI turn's state block again. The app strips it before storage,
# and the recent history has to show the model the pattern to follow.
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
texts = [_history_text(a) for a in included_actions]
note_sections: list[Section] = []
if authors_note:
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
@@ -431,14 +457,13 @@ def build_context(
# applies to the block that follows it, so this is also the order in which
# the model acts.
note_sections.append(Section("length_hint", length_note))
if has_ws:
# A correction for the previous turn sits directly above the reminder
# to emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("world_state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
# A correction for the previous turn sits directly above the reminder to
# emit a block, which is the instruction it modifies.
if refusal_note:
note_sections.append(Section("state_refusals", refusal_note))
# The emit rule sits in the system block, far from where the model
# generates text, so repeat it last where it has the most effect.
note_sections.append(Section("state_reminder", narrative.extract.EMIT_REMINDER))
story_sections = [s for s in note_sections if s.text]
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
+34
View File
@@ -314,6 +314,40 @@ def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
attempts.restore_state(adventure, node_at(db, adventure, depth))
def move_to_node(db: Session, adventure: models.Adventure, node: models.Action) -> bool:
"""Moves the head onto `node`, changing line only if it is not on this one.
M4 restores a Save Point through this, and it adds no restoring of its own:
the depth half is `move_to` unchanged, so the state, the transcript, the
assembled context and memory eligibility all arrive exactly as they do for
Undo and Redo. Returns whether the line had to change as well as the depth,
which is the one thing about a restore a caller cannot work out afterwards.
The head is two values, and the two halves move for different reasons. A
Save Point almost always names a position on the story being read — its own
line, or the shared prefix that line inherits — and then only the depth
moves. Leaving the branch alone is what makes the restored position keep the
continuation it has: after a divergence, restoring to the shared prefix must
put the reader back on the *new* line, where Redo walks into the turns they
are still writing, not into the future they left. Reaching for the Save
Point's own branch there would quietly hand back the abandoned story.
The other case is real and has to work. A Save Point survives divergence
(`STORY-BRANCH-SEMANTICS.md` §19), so one can name a position on a line the
story has since left, and no amount of depth movement reaches a branch this
path does not contain. The line then moves as well — one assignment, the
same one `switch_branch` makes — and the depth still moves through
`move_to`. Nothing is created: a restore never forks, whichever case it
takes. The first write below the restored head does, through
`fork_if_behind_head`, like every other write.
"""
switched = not lineage.path_of(db, adventure).uncapped().contains(node)
if switched:
adventure.head_branch_id = node.branch_id
move_to(db, adventure, node.depth)
return switched
def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool:
"""Gives the story a new branch when a write would displace a retained future.
+85 -3
View File
@@ -350,6 +350,58 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# No backfill.
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
# M4: Save Points. `create_all` creates the `checkpoints` table itself, on
# existing databases as well as fresh ones, exactly as it did for
# `memories` at version 2 and `branches` at version 46. What it does not
# create is the index every list and every cascade reads, so that is what
# this version is.
#
# No backfill. A Save Point records a decision someone made, and nobody has
# made one yet: an M3 database has no position a user chose to name, and
# 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)
@@ -363,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
@@ -380,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`.
@@ -1084,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)
@@ -1116,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)
+238
View File
@@ -119,7 +119,28 @@ class Adventure(Base):
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
# Phase 12: live RPG world state (world/player/npc stats + milestones),
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
#
# **Legacy as of M5**, and no longer authoritative. M5 replaced the
# relative-delta protocol this column served (ADR 010); the turn engine no
# longer writes it, and nothing reads it to decide anything. It stays so
# that a pre-M5 database opens unchanged and its numbers remain visible to
# whoever wants to look — `narrative_state` below is what the story means
# now. Reinterpreting these values as generic narrative facts would be
# inventing meaning the data does not carry, which the M5 brief forbids.
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
# M5: the authoritative narrative state, as it stands at the active head.
# Genre-neutral (ADR 006), written only by validated typed events (ADR 010),
# and restored from the destination node's snapshot whenever the head moves,
# so it always describes the story being read rather than a story the reader
# has stepped back from.
narrative_state: Mapped[dict] = mapped_column(
CompressedJSON, nullable=True, default=None
)
# Campaign canon: rules the story may not contradict, as configuration
# rather than code (C01, J03). A fantasy campaign forbidding resurrection
# and a science-fiction one forbidding faster-than-light travel use the same
# field and the same validator; neither word appears in the application.
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
@@ -241,6 +262,158 @@ class Branch(Base):
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Checkpoint(Base):
"""M4: a Save Point — a durable named pointer to a story position.
"Save Point" is what the user reads; `checkpoint` is what the code calls it
(`BROWSER-UX-SPEC.md` §23).
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
describes the pointer as naming a turn; the coordinate here is
`(branch_id, depth)`, which is what M3 made the head and what
`head.node_at` resolves. Restoring one is therefore head movement with a
bounds check rather than a restore system of its own — see ADR 012 and
`head.move_to_node`.
A coordinate rather than an action id, deliberately. One coordinate can
hold several attempts at a turn and exactly one of them is live, so a
retry replaces the row a Save Point would have pinned. "Turn 42 of this
line" survives a retry; "action 918" would point at a take the story no
longer tells.
`branch_id` is the branch the node itself sits on, not the branch that was
being read when the Save Point was made. Those differ whenever the head is
resting in a shared prefix, and the node's own branch is the one that still
names the position after the reader has moved elsewhere.
**Nothing removes a Save Point but the user.** They are not cleaned up for
going stale, for being behind the head, or for pointing into a future the
story has left (`STORY-BRANCH-SEMANTICS.md` §19).
That includes deleting a branch. `branch_id` carries `ON DELETE CASCADE` as
referential integrity — a Save Point must never point at a branch that is
gone — but the branch endpoint refuses to delete a branch any Save Point
names, so the cascade does not fire through the application
(`routers/adventures/branches.py`, `STORY-BRANCH-SEMANTICS.md` §19.1). The
user deletes the Save Point first, which deletes no story, and then the
branch. Deleting the whole campaign does cascade, and should: that is what
the user asked for.
"""
__tablename__ = "checkpoints"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120), default="")
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
# optional second field. Empty is the ordinary case.
note: Mapped[str] = mapped_column(Text, default="")
branch_id: Mapped[int] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE")
)
depth: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# Bumped by a rename, which is the only edit a Save Point allows. The
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
# Save Point's meaning auditable by making "move it" delete-and-recreate.
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
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.
@@ -443,10 +616,75 @@ class Action(Base):
world_state_after: Mapped[dict | None] = mapped_column(
JSON, nullable=True, deferred=True
)
# M5: the authoritative narrative state as it stood after this node played.
# The genre-neutral successor to `world_state_after`, and the reason Undo,
# Redo and Save Point restore stay bounded: a position's state is one row
# read, not a replay of every event since the campaign began
# (`TECHNICAL-DESIGN.md` §10.4, and the M4 note that made it load-bearing
# for Save Points too).
#
# `DATA-MODEL.md` §17 selects the hybrid — validated events for audit, a
# snapshot for reads and restore. `state_events` is the audit half; this
# column is the restore half, and nothing reconstructs a document from
# events.
#
# Deferred and compressed for the reasons `context_snapshot` is: only the
# single node being moved to reads it, and a document carrying a campaign's
# entities and facts is larger than the RPG dict it replaces. `world_delta`
# has an M5 counterpart in `state_changes` for the bulk read.
narrative_state_after: Mapped[dict | None] = mapped_column(
CompressedJSON, nullable=True, deferred=True
)
# The small slice needed in bulk: the events accepted here, the ones
# refused, and short lines for the chip under an AI message. Same role
# `world_delta` played, and a separate column for the same reason — the
# context builder reads it for every action in the replayed history, and
# the snapshot beside it is deferred so a turn never loads the prompt
# archive. Shape: {"accepted": [...], "rejected": [...], "summary": [...]}.
state_changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="actions")
@property
def state_events_replay(self) -> list[dict]:
"""M5: the accepted events this turn produced, for replay into the prompt.
Read from `state_changes`' companion slice in the bulk-read column
rather than from the deferred snapshot, because the context builder
calls this for every action in the replayed history and loading the
prompt archive per action is the egress mistake this project keeps a
regression test about.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
events = changes.get("accepted")
return events if isinstance(events, list) else []
@property
def state_rejections(self) -> list[dict]:
"""M5: what this turn proposed that the application refused.
Fed back to the model as a correction for one turn only. A refusal it
has already had a chance to fix is stale, and repeating it forever would
price one bad turn into the rest of the campaign.
"""
changes = self.state_changes
if not isinstance(changes, dict):
return []
rejected = changes.get("rejected")
return rejected if isinstance(rejected, list) else []
@property
def state_summary(self) -> list[str]:
"""M5: the short lines shown under an AI message: what changed here."""
changes = self.state_changes
if not isinstance(changes, dict):
return []
lines = changes.get("summary")
return [str(line) for line in lines] if isinstance(lines, list) else []
@property
def world_changes(self) -> list[dict]:
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
+23
View File
@@ -0,0 +1,23 @@
"""M5: the authoritative narrative state.
Genre-neutral state (ADR 006), written by explicit typed events with absolute
values (ADR 010), owned by the application rather than the model (ADR 003), and
recovered per story position rather than replayed (ADR 012 and
`TECHNICAL-DESIGN.md` §10.4).
extract.split(reply) prose out, proposal out, block kept for audit
|
validate.review(...) allowlist, schema, references, semantics
|
apply.apply_events(...) accepted events -> a new state document
|
store.commit_proposal(...) events, provenance and snapshot, in one transaction
`model.py` says what a state document is. `render.py` shows it to the model and
to the reader. Nothing outside this package writes authoritative state, and
nothing inside it executes anything a proposal names.
"""
from . import apply, events, extract, model, render, store, validate # noqa: F401
__all__ = ["apply", "events", "extract", "model", "render", "store", "validate"]
+272
View File
@@ -0,0 +1,272 @@
"""M5: turning accepted events into a new state document.
Pure and total. Every function here takes a document and returns a new one; none
touches the database, and none can fail on an event `validate.review` accepted —
validation is the only place an event is refused, so this module never has to
decide anything twice.
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
table keyed on the payload. The difference matters: a table maps a string a model
supplied to a callable, and the security of that arrangement rests entirely on
the allowlist being correct. A chain of literal comparisons cannot be steered by
a payload at all, whatever the allowlist does.
"""
from __future__ import annotations
import copy
from . import model
def apply_events(
state: dict,
accepted: list[dict],
*,
branch_id: int | None = None,
depth: int | None = None,
source: str = "accepted_story",
) -> dict:
"""Returns `state` with every event in `accepted` applied, in order.
The input document is never mutated: head movement stores snapshots by
reference in places, and a mutation here would edit the past.
`branch_id`/`depth` stamp facts and relationships with where they were
established, which is what makes the audit trail answer "which turn caused
this" without a join. `source` records whether the campaign, the story or
the user established it — C04's provenance, carried on the value itself.
"""
document = model.normalize(state)
for event in accepted:
_apply_one(document, event, branch_id, depth, source)
return document
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
kind = event["type"]
if kind == "create_entity":
state["entities"][event["entity"]] = model.new_entity(
type=event.get("entity_type") or "other",
name=event["name"],
description=event.get("description") or "",
aliases=event.get("aliases") or [],
)
elif kind == "set_entity_status":
_entity(state, event["entity"])["status"] = event["status"]
elif kind == "set_entity_attribute":
# Absolute assignment. The whole reason ADR 010 exists.
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
elif kind == "set_entity_conditions":
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
elif kind == "set_current_location":
_entity(state, event["entity"])["location"] = event["location"]
elif kind == "set_possession":
state["possessions"][event["item"]] = event["owner"]
elif kind == "clear_possession":
state["possessions"].pop(event["item"], None)
elif kind == "add_fact":
state["facts"].append({
"id": event.get("fact_id") or _fact_id(state),
"subject": event.get("subject"),
"predicate": event["predicate"],
"object": event.get("object"),
"value": event.get("value"),
"authority": _authority(source),
"source": source,
"status": "active",
"branch_id": branch_id,
"depth": depth,
})
elif kind == "invalidate_fact":
for fact in state["facts"]:
if fact.get("id") == event["fact_id"]:
# Withdrawn, not removed: C04 needs the record of what the
# campaign used to believe, and a deleted row audits nothing.
fact["status"] = "invalidated"
fact["invalidated_by"] = source
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
if event.get("reason"):
fact["invalidated_reason"] = event["reason"]
elif kind == "add_relationship":
state["relationships"].append({
"id": _relationship_id(state),
"source": event["source"],
"target": event["target"],
"type": event["relationship"],
"description": event.get("description") or "",
"status": "active",
"established_by": source,
"branch_id": branch_id,
"depth": depth,
})
elif kind == "end_relationship":
for relationship in state["relationships"]:
if (
relationship.get("source") == event["source"]
and relationship.get("target") == event["target"]
and relationship.get("type") == event["relationship"]
and relationship.get("status") == "active"
):
relationship["status"] = "ended"
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "open_story_thread":
state["threads"][event["thread"]] = {
"title": event["title"],
"description": event.get("description") or "",
"status": "open",
"opened_at": {"branch_id": branch_id, "depth": depth},
}
elif kind == "resolve_story_thread":
thread = state["threads"].get(event["thread"])
if isinstance(thread, dict):
thread["status"] = "resolved"
thread["resolution"] = event.get("resolution") or ""
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "set_scene":
scene = dict(state.get("scene") or {})
if "summary" in event:
scene["summary"] = event["summary"]
if "location" in event:
scene["location"] = event["location"]
if "present" in event:
scene["present"] = list(event["present"] or [])
scene["at"] = {"branch_id": branch_id, "depth": depth}
state["scene"] = scene
# No `else`. Every allowed type is handled above, and an unhandled one
# cannot arrive: `validate.review` refuses anything outside the allowlist,
# and the allowlist is this list. A silent fall-through would be the one way
# an event could appear accepted and do nothing.
def _entity(state: dict, key: str) -> dict:
"""The entity record for `key`, created bare if a snapshot lost it.
Validation guarantees the entity exists, so this is a repair path for a
hand-edited or partially imported document rather than a normal branch. A
bare record is better than a KeyError: the story is still readable, and the
inspector shows an entity with nothing known about it, which is true.
"""
entities = state["entities"]
found = entities.get(key)
if not isinstance(found, dict):
found = model.new_entity(name=key)
entities[key] = found
found.setdefault("attributes", {})
found.setdefault("conditions", [])
return found
def _authority(source: str) -> str:
"""Which authority band a source's assertions carry.
A user's correction outranks the story (C04); the story outranks a guess.
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
"""
if source == "manual_correction":
return "manual_correction"
if source == "campaign_canon":
return "campaign_canon"
return "accepted_story"
def _fact_id(state: dict) -> str:
return f"f{len(state['facts']) + 1}"
def _relationship_id(state: dict) -> str:
return f"r{len(state['relationships']) + 1}"
def diff(before: dict, after: dict) -> list[str]:
"""A short human-readable list of what changed between two documents.
Shown under a turn the way the world-state chip used to be, and recorded on
the node for the bulk read. Text rather than structure, because its only
consumer is a person reading "Aldric now holds the silver key".
"""
before = model.normalize(before)
after = model.normalize(after)
lines: list[str] = []
for key, entity in after["entities"].items():
was = before["entities"].get(key)
name = model.entity_name(after, key)
if was is None:
lines.append(f"{name} enters the story")
continue
if was.get("status") != entity.get("status"):
lines.append(f"{name} is now {entity.get('status')}")
if was.get("location") != entity.get("location") and entity.get("location"):
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
now = ", ".join(entity.get("conditions") or []) or "nothing"
lines.append(f"{name}: {now}")
for attribute, value in (entity.get("attributes") or {}).items():
if (was.get("attributes") or {}).get(attribute) != value:
lines.append(f"{name} {attribute} = {value}")
for item, owner in after["possessions"].items():
if before["possessions"].get(item) != owner:
lines.append(
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
)
for item in before["possessions"]:
if item not in after["possessions"]:
lines.append(f"{model.entity_name(after, item)} is held by nobody")
known = {f.get("id") for f in before["facts"]}
for fact in after["facts"]:
if fact.get("id") not in known:
lines.append(f"fact: {_fact_text(after, fact)}")
was_active = {f["id"] for f in model.active_facts(before)}
for fact in before["facts"]:
if fact.get("id") in was_active and fact.get("id") not in {
f["id"] for f in model.active_facts(after)
}:
lines.append(f"withdrawn: {_fact_text(after, fact)}")
known = {r.get("id") for r in before["relationships"]}
for relationship in after["relationships"]:
if relationship.get("id") not in known:
lines.append(
f"{model.entity_name(after, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(after, relationship['target'])}"
)
for key, thread in after["threads"].items():
was = before["threads"].get(key)
if was is None:
lines.append(f"opened: {thread.get('title', key)}")
elif was.get("status") != thread.get("status"):
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
return lines
def _fact_text(state: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(state, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(state, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
return " ".join(p for p in parts if p)
+194
View File
@@ -0,0 +1,194 @@
"""M5: the typed event vocabulary, and the allowlist that bounds it.
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
architectural: a number in a delta field is syntactically legal whether the
model meant "add 50" or "set to 50", and no validator can tell which. Every
event here therefore states its operation in its `type`, and every value it
carries is **absolute**. There is no event whose meaning depends on a prompt
instruction having been followed.
## The allowlist is a security boundary, not a convenience
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
the entire set of things a model may cause to happen. H05's
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
recognised and blocked, but because it is not in `SPECS`, and nothing outside
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
name-to-callable lookup that a payload could steer.
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
in the application can widen the vocabulary, which is what keeps
"state extraction" from drifting into "tool execution".
## Shape of a spec
required fields that must be present and non-empty
optional fields that may be present
refs fields naming an entity that must already exist
creates the field naming an entity this event may bring into being
`refs` is what `validate.py` uses for referential integrity, and `creates` is
the deliberate exception: exactly one event type may introduce an entity, so a
typo in any other event surfaces as an unknown reference rather than silently
creating a second, empty Mara.
"""
from __future__ import annotations
# Field types the schema layer enforces. Kept deliberately small: a narrative
# state event carries names, labels and plain values, and nothing here needs a
# nested structure a model could hide something inside.
TEXT = "text"
KEY = "key" # an entity/thread identifier: a slug the campaign chose
VALUE = "value" # a JSON scalar — str, int, float, bool or None
LABELS = "labels" # a list of short strings
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
SPECS: dict[str, dict] = {
"create_entity": {
"required": {"entity": KEY, "name": TEXT},
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
"refs": (),
"creates": "entity",
"summary": "brings a person, place, thing or group into the story",
},
"set_entity_status": {
"required": {"entity": KEY, "status": TEXT},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets whether an entity is active, gone, destroyed …",
},
"set_entity_attribute": {
# The one numeric-capable event, and it is an assignment. ADR 010's
# `set_value`: the operation is in the name, so a value of 50 can only
# mean fifty. An `increment_value` could be added later without
# ambiguity, because it would be a different `type`.
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "sets a named value on an entity, absolutely",
},
"set_entity_conditions": {
# Absolute too: the full set replaces the old one. "Add a condition"
# would need the current set to be known by the model, which is exactly
# the assumption that made deltas unreliable.
"required": {"entity": KEY, "conditions": LABELS},
"optional": {},
"refs": ("entity",),
"creates": None,
"summary": "replaces the conditions an entity is under",
},
"set_current_location": {
"required": {"entity": KEY, "location": KEY},
"optional": {},
"refs": ("entity", "location"),
"creates": None,
"summary": "moves an entity to a location",
},
"set_possession": {
"required": {"item": KEY, "owner": KEY},
"optional": {},
"refs": ("item", "owner"),
"creates": None,
"summary": "gives an item to an owner",
},
"clear_possession": {
"required": {"item": KEY},
"optional": {},
"refs": ("item",),
"creates": None,
"summary": "leaves an item held by nobody",
},
"add_fact": {
"required": {"predicate": TEXT},
"optional": {
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
},
# Only the subject is checked as an entity. The *object* of a fact is
# routinely not one — "Mara knows where the key was found" has another
# fact as its object, and C03 needs exactly that — so it is checked
# against entities *and* known facts in `validate._check`. Requiring an
# entity here would make the knowledge distinction C03 asks for
# unrepresentable.
"refs": ("subject",),
"creates": None,
"summary": "asserts something about the world",
},
"invalidate_fact": {
"required": {"fact_id": TEXT},
"optional": {"reason": TEXT},
"refs": (),
"creates": None,
"summary": "withdraws a fact without deleting the record of it",
},
"add_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {"description": TEXT},
"refs": ("source", "target"),
"creates": None,
"summary": "ties two entities together",
},
"end_relationship": {
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
"optional": {},
"refs": ("source", "target"),
"creates": None,
"summary": "ends a tie without erasing that it existed",
},
"open_story_thread": {
"required": {"thread": KEY, "title": TEXT},
"optional": {"description": TEXT},
"refs": (),
"creates": None,
"summary": "records narrative business left open",
},
"resolve_story_thread": {
"required": {"thread": KEY},
"optional": {"resolution": TEXT},
"refs": (),
"creates": None,
"summary": "closes narrative business",
},
"set_scene": {
"required": {},
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
"refs": ("location",),
"creates": None,
"summary": "records the immediate situation",
},
}
#: The allowlist itself, as a set, for the one question that matters most.
ALLOWED = frozenset(SPECS)
def is_allowed(event_type) -> bool:
"""Whether `event_type` names an event this application will ever apply.
A string is required: a dict, a list or None is not a type, and coercing one
with `str()` would turn a malformed payload into a lookup that might
accidentally succeed.
"""
return isinstance(event_type, str) and event_type in ALLOWED
def spec(event_type: str) -> dict | None:
return SPECS.get(event_type)
def vocabulary_for_prompt() -> str:
"""The event list as the narrator prompt describes it.
Generated from `SPECS` rather than written out beside it, so the model can
never be told about an event the application does not implement — the drift
that would produce proposals rejected for reasons nobody could see.
"""
lines = []
for name, definition in SPECS.items():
fields = list(definition["required"]) + [
f"{field}?" for field in definition["optional"]
]
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
return "\n".join(lines)
+259
View File
@@ -0,0 +1,259 @@
"""M5: getting a typed proposal out of a narration, and keeping it out of the prose.
The model writes the story and, after it, one fenced block of typed events. This
module holds the instruction it is given, the parser that survives the ways a
model gets a format wrong, and the separation that keeps machine-readable output
from reaching the reader.
Two properties matter more than elegance here:
* **The prose must never carry the protocol.** A reader should not see a JSON
block under their story, and a stored narration should not contain one either,
because everything downstream — memory, summaries, export, the transcript —
treats stored text as the story. The block is removed before the text is
stored, not before it is displayed.
* **An unreadable block must not be a failed turn.** A narration the user watched
arrive is worth keeping even when the state block after it is garbage. Parsing
returns "no events" rather than raising, the turn commits with the state
unchanged, and the proposal record keeps the raw output so the failure is
visible in the audit rather than only in a log.
"""
from __future__ import annotations
import json
import re
from . import events
# The block the model is asked to append. Built from the vocabulary rather than
# written beside it, so the instruction cannot describe an event the application
# would then reject (`events.vocabulary_for_prompt`).
EMIT_RULE = (
"After your narration, append a fenced code block labelled `state` containing "
"a JSON object with an \"events\" list, recording what your own narration made "
"true. Treat your narration as authoritative: if you wrote that someone moved, "
"took something, learned something, was hurt, or that a new person or place "
"appeared, record it.\n"
"\n"
"Every value is ABSOLUTE — the new state of things, never a change or a "
"difference. Use only these events:\n"
f"{events.vocabulary_for_prompt()}\n"
"\n"
"Identifiers are short lower-case slugs (mara, silver-key, old-abbey) and must "
"match the ones already in the state you were shown. Introduce a person, place "
"or thing with create_entity before referring to it. If the turn established "
"nothing, send an empty events list.\n"
"Example:\n"
'```state\n'
'{"events": [{"type": "set_possession", "item": "silver-key", "owner": "aldric"},'
' {"type": "set_current_location", "entity": "aldric", "location": "old-abbey"}]}\n'
'```'
)
# Placed last, where recency is strongest, the same way the delta protocol did.
EMIT_REMINDER = (
"[Reminder: end your reply with a ```state block listing the events your "
"narration made true, with absolute values. Send an empty events list if "
"nothing changed.]"
)
# Three patterns, and the difference between them is the whole of this module's
# safety. A story is allowed to contain code, and taking a code block out of
# someone's prose is a worse failure than leaving a stray proposal in it.
#
# `state` is the label the application asks for, so a fence carrying it is ours
# whatever is inside it — including a truncated `{oh no` that no JSON parser
# will take. That block must still leave the prose, and must still be recorded,
# because an unparseable proposal is exactly the failure the audit exists to
# make visible.
_STATE_FENCE_RE = re.compile(
r"```state[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# `json` is *not* our label. Models reach for it anyway, so a ```json fence is
# taken only when what it contains is actually a proposal. A character who
# writes `{"name": "Mara"}` into a terminal keeps their code block (M5 review,
# Finding 6).
_JSON_FENCE_RE = re.compile(
r"```json[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
)
# An *unlabelled* fence is ours on the same terms: it has to be a proposal, not
# merely JSON-shaped.
_BARE_FENCE_RE = re.compile(r"```\s*([\[{].*?[\]}])\s*```", re.DOTALL)
# A bare object hugging the end of the text, for a model that forgets the fence.
_TRAILING_RE = re.compile(r"(\{.*\})\s*$", re.DOTALL)
# An opener with no closing fence. A model that runs out of output tokens
# mid-block leaves one of these, and everything after it is protocol rather than
# story — so the story ends where the opener begins.
#
# Our own label ends the story unconditionally. A dangling ```json fence is
# judged on what follows it, because an unterminated code block in a story is
# still the author's (M5 review, Finding 6).
_DANGLING_STATE_RE = re.compile(r"\n?```state\b.*\Z", re.DOTALL | re.IGNORECASE)
_DANGLING_JSON_RE = re.compile(r"\n?```json\b(.*)\Z", re.DOTALL | re.IGNORECASE)
# The reminder, parroted back. Small local models reproduce the bracketed
# instruction they were given, and it arrives as ordinary prose — no fence, so
# nothing above strips it, and the reader is shown a piece of the prompt.
#
# The bracket is *found* broadly and *judged* narrowly. Merely naming the
# protocol is not enough: a story may end on an aside about a state block, and
# deleting that sentence is the worse failure (M5 review, Finding 6). What marks
# the echo is the shape of the instruction itself — the fence token, the word it
# opens with, or the pair of phrases the reminder uses together.
_TRAILING_BRACKET_RE = re.compile(r"\n?\[([^\]]*)\]\s*\Z", re.DOTALL)
def _is_echoed_instruction(inner: str) -> bool:
"""Whether a trailing bracketed segment is the prompt's own reminder."""
low = inner.lower()
if "```state" in low:
return True
if low.lstrip().startswith("reminder:"):
return True
# The reminder names both; prose about the protocol rarely names either the
# way the instruction does, and effectively never both.
return "state block" in low and "events list" in low
def _clean(prose: str) -> str:
"""Removes protocol the block extraction could not, and nothing else.
Found by the M5 realistic-context run (§12), which is the failure class
Phase 0B warned about: under a full prompt the model echoed its own
instruction into the narration, and the reader would have been shown it.
Neither case here is hypothetical — both were observed against a real local
model.
"""
cleaned = prose
bracket = _TRAILING_BRACKET_RE.search(cleaned)
if bracket is not None and _is_echoed_instruction(bracket.group(1)):
cleaned = cleaned[: bracket.start()]
cleaned = _DANGLING_STATE_RE.sub("", cleaned)
dangling = _DANGLING_JSON_RE.search(cleaned)
if dangling is not None and _reads_as_protocol(dangling.group(1)):
cleaned = cleaned[: dangling.start()]
return cleaned.strip()
def _reads_as_protocol(tail: str) -> bool:
"""Whether a truncated fence was on its way to being a proposal."""
if '"events"' in tail:
return True
return any(f'"{name}"' in tail for name in events.SPECS)
def _tolerant_load(blob: str):
"""Parses a block, forgiving what small local models get wrong.
Trailing commas and a leading `+` on a number are both common and both
rejected by strict JSON. Repairing them is not guessing at meaning — the
intended value is unambiguous — which is the line this function stays on the
right side of. Anything it cannot parse returns None, and the caller treats
that as no proposal rather than as an empty one.
"""
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
return None
return parsed
def split(text: str) -> tuple[str, dict | None, str]:
"""Separates a reply into `(prose, proposal, raw_block)`.
`proposal` is None when there is no block or it cannot be parsed at all,
which the caller records as a malformed proposal. `raw_block` is what the
model actually wrote, kept for the audit record even — especially — when it
did not parse.
A bare trailing object is only stripped when it parses *and* looks like a
proposal. Prose that happens to end in a brace is left alone, because
removing a sentence from someone's story to satisfy a regex is a worse
failure than leaving a stray brace in it.
"""
matches = list(_STATE_FENCE_RE.finditer(text))
if matches:
match = matches[-1]
raw = match.group(1).strip()
prose = _clean(text[: match.start()] + text[match.end():])
return prose, _tolerant_load(raw), raw
# A `json` or unlabelled fence is ours only when its contents are this
# protocol. That is judged two ways, and it needs both: a block that parses
# into a proposal, or one that plainly reads as protocol even though it does
# not parse. The second half matters — a small model that mangles its own
# JSON must not have the wreckage shown to the reader, which is what the
# realistic-model run caught during the corrective pass.
for pattern in (_JSON_FENCE_RE, _BARE_FENCE_RE):
for match in reversed(list(pattern.finditer(text))):
raw = match.group(1).strip()
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed) or _reads_as_protocol(raw):
prose = _clean(text[: match.start()] + text[match.end():])
return prose, parsed, raw
match = _TRAILING_RE.search(text)
if match:
raw = match.group(1)
parsed = _tolerant_load(raw)
if _looks_like_proposal(parsed):
return _clean(text[: match.start()]), parsed, raw
# No block at all — but the reply may still carry protocol the model wrote
# as prose, or a fence it never closed.
cleaned = _clean(text)
if cleaned != text.strip():
return cleaned, None, text.strip()[len(cleaned):].strip()
return cleaned, None, ""
def _looks_like_proposal(parsed) -> bool:
"""Whether a bare trailing object is this protocol rather than prose."""
if not isinstance(parsed, dict):
return False
if isinstance(parsed.get("events"), list):
return True
return isinstance(parsed.get("type"), str) and events.is_allowed(parsed["type"])
def render_block(accepted: list[dict]) -> str:
"""Renders accepted events back into the block the model emitted.
Replayed into the prompt for past turns so the model copies the format it is
being asked for. **Accepted** events rather than proposed ones, for the
reason the delta protocol learned the hard way: showing the model a refused
event standing as though it had worked, contradicted by the state in the
same prompt, teaches it to send the event again.
"""
if not accepted:
return ""
return "```state\n" + json.dumps({"events": accepted}, ensure_ascii=False) + "\n```"
def render_rejections(rejected: list[dict]) -> str:
"""The correction note appended after the most recent AI turn.
Only what was lost. A model that is told what it got wrong can fix it next
turn; a model told nothing repeats it.
"""
if not rejected:
return ""
lines = []
for entry in rejected[:6]:
if not isinstance(entry, dict):
continue
detail = entry.get("detail") or entry.get("reason") or ""
if detail:
lines.append(f"- {detail}")
if not lines:
return ""
body = "\n".join(lines)
return (
"[Part of your last state block was not accepted. Correct it in this "
f"turn's block:\n{body}]"
)
+307
View File
@@ -0,0 +1,307 @@
"""M5: the authoritative narrative state, and what shape it has.
This is the genre-neutral state ADR 006 requires and ADR 010's typed events
write into. It replaces the inherited RPG world state, which assumed stats,
bands, cooldowns and per-turn delta caps — assumptions that are a *game system*,
not a story.
## What a state document is
One JSON document per story position, holding what the campaign currently
believes:
entities the things that exist: who, where, what
possessions which entity holds which item
facts assertions about the world, with an authority
relationships directed ties between entities
threads narrative business that is open or resolved
scene the immediate situation
Nothing here names a genre. A character, a location, an organization, an item
and a vehicle are all `entities` with a `type`, which is a descriptive label the
campaign chooses, not a branch in the code (`DATA-MODEL.md` §9). The same
document holds Aldric in an abbey and the Persephone at Ceres Station, and
`J03` is satisfied because moving between them is data.
## Why a document rather than normalised tables
`DATA-MODEL.md` §17 selects the **hybrid**: validated events for audit, plus a
snapshot for reads and restore. M3 and M4 make that choice load-bearing rather
than an optimisation. Every position in a retained story must be recoverable in
bounded time — `TECHNICAL-DESIGN.md` §10.4 — because Undo, Redo and Save Point
restore all resolve a coordinate and read the state recorded there. Current-value
tables would leave the *future's* values standing when the head moves back, which
`BUILD-MILESTONES.md` M5 forbids in as many words, and rebuilding them would mean
replaying the campaign.
So the authoritative current state is this document, snapshotted per node exactly
as the world state was, and the event log beside it is the audit record rather
than the reconstruction path. The events say *why* the document changed; the
document says what is true now.
Everything in this module is pure. It builds and reads documents; it does not
touch the database, and it does not decide whether a proposal is acceptable —
that is `validate.py`, and applying an accepted event is `apply.py`.
"""
from __future__ import annotations
import copy
# The document version, so a later milestone can migrate a stored snapshot
# without guessing what it was written by. Bump only for a shape change that a
# reader cannot infer.
VERSION = 1
# Entity categories the product suggests. This is a vocabulary, not a
# constraint: `DATA-MODEL.md` §9 calls these "descriptive categories, not
# separate game systems", so an unknown type is accepted and simply described.
# Rejecting one would make the schema genre-specific by the back door.
SUGGESTED_TYPES = (
"character", "location", "organization", "item", "vehicle",
"creature", "structure", "concept", "other",
)
# Entity lifecycle status. `DATA-MODEL.md` §9.
ENTITY_STATUSES = ("active", "inactive", "destroyed", "dead", "unknown")
# Where a fact came from, in descending authority. `DATA-MODEL.md` §14 lists the
# minimum categories; the order here is what a later context builder ranks by.
AUTHORITIES = (
"campaign_canon", # the campaign's own rules — the highest
"manual_correction", # the user said so, explicitly (C04)
"accepted_story", # derived from narration the user accepted
"current_state",
"imported_canon", # M7
"reference", # M7
"heuristic",
"inspiration", # M7
)
FACT_STATUSES = ("active", "superseded", "disputed", "invalidated")
THREAD_STATUSES = ("open", "dormant", "resolved", "abandoned")
RELATIONSHIP_STATUSES = ("active", "ended")
def empty() -> dict:
"""A campaign that has established nothing yet.
Every key is present, so no reader needs a `.get` with a default and no
writer has to decide whether a section exists. An empty document is a real
document, not a missing one.
"""
return {
"version": VERSION,
"entities": {},
"possessions": {},
"facts": [],
"relationships": [],
"threads": {},
"scene": {},
}
def normalize(state) -> dict:
"""Returns `state` as a well-formed document, repairing what it can.
Called on every read of a stored snapshot. A document can arrive from a
hand-edited database, an imported bundle, or a snapshot written by an older
version of this module, and a read must not raise on any of them: the story
is the valuable thing, and a malformed state section should cost the
section, not the campaign.
Repair is deliberately shallow — wrong-typed sections are replaced with
empty ones rather than coerced, because guessing what a malformed section
meant is exactly the kind of invention `§19` of the M5 brief forbids.
"""
if not isinstance(state, dict):
return empty()
out = empty()
out["version"] = state.get("version") if isinstance(state.get("version"), int) else VERSION
for key in ("entities", "possessions", "threads", "scene"):
value = state.get(key)
if isinstance(value, dict):
out[key] = copy.deepcopy(value)
for key in ("facts", "relationships"):
value = state.get(key)
if isinstance(value, list):
out[key] = copy.deepcopy([item for item in value if isinstance(item, dict)])
return out
def is_empty(state) -> bool:
"""Whether a document says nothing about the world.
`version` alone does not count as content, so a freshly created campaign
reads as empty and the prompt builder can leave the section out entirely
rather than showing a heading with nothing under it.
"""
document = normalize(state)
return not any(
document[key] for key in
("entities", "possessions", "facts", "relationships", "threads", "scene")
)
# ------------------------------------------------------------------ entities
def entity(state: dict, key: str) -> dict | None:
"""Returns the entity stored under `key`, or None."""
entities = state.get("entities")
if not isinstance(entities, dict):
return None
found = entities.get(key)
return found if isinstance(found, dict) else None
def entity_name(state: dict, key: str) -> str:
"""The display name for `key`, falling back to the key itself.
A key is a slug the campaign chose, so it is readable enough to show when an
entity was referenced before it was described.
"""
found = entity(state, key)
if found and isinstance(found.get("name"), str) and found["name"].strip():
return found["name"]
return key
def new_entity(
*, type: str = "other", name: str = "", description: str = "",
status: str = "active", aliases: list | None = None,
) -> dict:
return {
"type": type or "other",
"name": name,
"description": description,
"status": status or "active",
"aliases": list(aliases or []),
# Where this entity currently is, as another entity's key. None means
# the campaign has not placed it, which is different from placing it
# nowhere.
"location": None,
# Free-form condition labels: "injured", "depressurised", "asleep".
# Labels rather than numbers, because a number implies a scale and a
# scale implies a game system.
"conditions": [],
# Named values the campaign cares about. Genre-neutral by construction:
# the campaign chooses the names, and every write is an absolute
# assignment (ADR 010).
"attributes": {},
}
def entities_of_type(state: dict, wanted: str) -> dict:
"""Every entity whose `type` matches, keyed as they are stored."""
entities = state.get("entities")
if not isinstance(entities, dict):
return {}
return {
key: value for key, value in entities.items()
if isinstance(value, dict) and value.get("type") == wanted
}
# --------------------------------------------------------------- possessions
def owner_of(state: dict, item_key: str) -> str | None:
"""Which entity holds `item_key`, or None if nobody does.
Possession is stored as one map from item to owner rather than as a list per
owner, because an item has exactly one holder and the map makes that
structural. Two owners for one item is then unrepresentable rather than
merely invalid.
"""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return None
owner = possessions.get(item_key)
return owner if isinstance(owner, str) else None
def held_by(state: dict, owner_key: str) -> list[str]:
"""Every item `owner_key` currently holds, in stable order."""
possessions = state.get("possessions")
if not isinstance(possessions, dict):
return []
return sorted(
item for item, owner in possessions.items() if owner == owner_key
)
# ------------------------------------------------------------------- facts
def withdrawn_facts(state: dict) -> list[dict]:
"""Facts a correction or retcon took back, newest last.
The prompt needs these as well as the ones that stand. Dropping a withdrawn
fact silently leaves the narration that first asserted it as the only
account in the prompt, and the model reads surviving prose as current truth
(M5 review, Finding 4). Naming the withdrawal is what makes the reader's
correction win.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status") == "invalidated"
]
def active_facts(state: dict) -> list[dict]:
"""Facts that still stand, newest last.
An invalidated fact stays in the document rather than being removed. C04
requires a correction to be auditable, and a fact that vanished would leave
nothing to audit — the record of what the campaign used to believe is the
point.
"""
facts = state.get("facts")
if not isinstance(facts, list):
return []
return [
fact for fact in facts
if isinstance(fact, dict) and fact.get("status", "active") == "active"
]
def facts_about(state: dict, subject_key: str) -> list[dict]:
return [f for f in active_facts(state) if f.get("subject") == subject_key]
def knows(state: dict, subject_key: str, object_key: str) -> bool:
"""Whether an accepted fact says `subject` knows `object`.
C03's question, asked the way the state model can answer it. "The campaign
knows X" is a fact with no subject; "Mara knows X" is a fact whose subject
is Mara. The distinction is structural, so nothing has to infer it.
"""
return any(
fact.get("predicate") == "knows" and fact.get("object") == object_key
for fact in facts_about(state, subject_key)
)
# ----------------------------------------------------------- relationships
def active_relationships(state: dict) -> list[dict]:
relationships = state.get("relationships")
if not isinstance(relationships, list):
return []
return [
r for r in relationships
if isinstance(r, dict) and r.get("status", "active") == "active"
]
# ---------------------------------------------------------------- threads
def open_threads(state: dict) -> dict:
threads = state.get("threads")
if not isinstance(threads, dict):
return {}
return {
key: value for key, value in threads.items()
if isinstance(value, dict) and value.get("status", "open") in ("open", "dormant")
}
+291
View File
@@ -0,0 +1,291 @@
"""M5: showing the narrative state — to the model, and to the reader.
Two audiences, one document, and they want different things. The model needs the
state compactly, in the vocabulary it must answer in, close to where it
generates. The reader needs it grouped and named, in the words the campaign uses.
Both are read-only views. Neither can change state, and the browser gets its own
data from the API rather than from anything assembled here, because
`BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and
must not become the owner of state.
"""
from __future__ import annotations
from . import model
# How much of a long section reaches the prompt. A campaign accumulates facts
# faster than it accumulates anything else, and the context budget is finite;
# the newest are the ones the current scene is most likely to need. M6 owns
# retrieval-ranked selection, so this is deliberately a simple recency cut and
# is documented as such rather than pretending to be a relevance model.
PROMPT_FACTS = 30
PROMPT_RELATIONSHIPS = 20
PROMPT_THREADS = 12
def for_prompt(state) -> str:
"""The current state as the narrator is shown it.
Empty string when the campaign has established nothing, so a new story's
prompt carries no heading with nothing under it.
"""
document = model.normalize(state)
if model.is_empty(document):
return ""
lines: list[str] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
where = scene.get("location")
head = "Scene: " + str(scene.get("summary") or "").strip()
if where:
head += f" (at {model.entity_name(document, where)})"
lines.append(head.strip())
entities = document["entities"]
if entities:
lines.append("")
lines.append("Who and what exists:")
for key, entity in entities.items():
lines.append(f" {key}: {_entity_line(document, key, entity)}")
possessions = document["possessions"]
if possessions:
lines.append("")
lines.append("Held:")
for item, owner in sorted(possessions.items()):
lines.append(
f" {model.entity_name(document, item)} — "
f"{model.entity_name(document, owner)}"
)
facts = model.active_facts(document)
if facts:
lines.append("")
lines.append("Established:")
for fact in facts[-PROMPT_FACTS:]:
lines.append(f" {_fact_line(document, fact)}")
# What the campaign has taken back. Placed straight after what stands, so
# the contradiction is resolved in the same breath it could be raised: the
# story above may still narrate the moment, and this says it did not hold
# (C04, M5 review Finding 4).
withdrawn = model.withdrawn_facts(document)
if withdrawn:
lines.append("")
lines.append("No longer true — do not treat these as established:")
for fact in withdrawn[-PROMPT_FACTS:]:
line = f" {_fact_line(document, fact)}"
reason = fact.get("invalidated_reason")
if reason:
line += f" — {reason}"
lines.append(line)
relationships = model.active_relationships(document)
if relationships:
lines.append("")
lines.append("Between them:")
for relationship in relationships[-PROMPT_RELATIONSHIPS:]:
lines.append(
f" {model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
)
threads = model.open_threads(document)
if threads:
lines.append("")
lines.append("Still open:")
for key, thread in list(threads.items())[:PROMPT_THREADS]:
lines.append(f" {key}: {thread.get('title', key)}")
return "\n".join(lines).strip()
def _entity_line(document: dict, key: str, entity: dict) -> str:
parts = [entity.get("name") or key]
kind = entity.get("type")
if kind and kind != "other":
parts.append(f"({kind})")
status = entity.get("status")
if status and status != "active":
parts.append(f"[{status}]")
where = entity.get("location")
if where:
parts.append(f"at {model.entity_name(document, where)}")
conditions = entity.get("conditions") or []
if conditions:
parts.append("— " + ", ".join(conditions))
attributes = entity.get("attributes") or {}
if attributes:
parts.append(
"— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items()))
)
return " ".join(str(p) for p in parts)
def _fact_line(document: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(document, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(document, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
line = " ".join(str(p) for p in parts if p)
if fact.get("authority") == "manual_correction":
# The reader corrected this. Saying so in the prompt is what stops the
# model re-deriving the thing the correction removed.
line += " [corrected by the player]"
return line
def for_inspector(state) -> dict:
"""The current state grouped for the browser panel.
Only categories that actually hold something are returned, so the panel can
render what it is given without deciding what to hide — a category with no
rows is a heading that tells the reader nothing.
Every entry carries the key as well as the name. The key is what a manual
correction has to name, so the panel can offer a correction without the user
having to guess at an identifier.
"""
document = model.normalize(state)
groups: list[dict] = []
scene = document.get("scene") or {}
if scene.get("summary") or scene.get("location"):
rows = []
if scene.get("summary"):
rows.append({"key": "summary", "label": str(scene["summary"])})
if scene.get("location"):
rows.append({
"key": scene["location"],
"label": model.entity_name(document, scene["location"]),
"detail": "location",
})
groups.append({"title": "Current Scene", "rows": rows})
by_type: dict[str, list] = {}
for key, entity in document["entities"].items():
by_type.setdefault(entity.get("type") or "other", []).append((key, entity))
# Characters and locations first because they are what a reader looks for;
# everything else in whatever categories the campaign actually used, so a
# science-fiction campaign's `vehicle` appears without this code knowing the
# word (J02).
order = ["character", "location"] + sorted(
set(by_type) - {"character", "location"}
)
for kind in order:
members = by_type.get(kind)
if not members:
continue
rows = []
for key, entity in sorted(members):
detail = []
if entity.get("status") and entity["status"] != "active":
detail.append(str(entity["status"]))
if entity.get("location"):
detail.append("at " + model.entity_name(document, entity["location"]))
if entity.get("conditions"):
detail.append(", ".join(entity["conditions"]))
for name, value in sorted((entity.get("attributes") or {}).items()):
detail.append(f"{name}: {value}")
held = model.held_by(document, key)
if held:
detail.append(
"carrying " + ", ".join(model.entity_name(document, i) for i in held)
)
rows.append({
"key": key,
"label": entity.get("name") or key,
"detail": " · ".join(detail),
})
groups.append({"title": _title_for(kind), "rows": rows})
possessions = document["possessions"]
if possessions:
groups.append({"title": "Possessions", "rows": [
{
"key": item,
"label": model.entity_name(document, item),
"detail": "held by " + model.entity_name(document, owner),
}
for item, owner in sorted(possessions.items())
]})
facts = model.active_facts(document)
if facts:
groups.append({"title": "Important Facts", "rows": [
{
"key": fact.get("id") or "",
"label": _fact_line(document, fact),
"detail": _source_label(fact),
}
for fact in facts
]})
relationships = model.active_relationships(document)
if relationships:
groups.append({"title": "Relationships", "rows": [
{
"key": relationship.get("id") or "",
"label": (
f"{model.entity_name(document, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(document, relationship['target'])}"
),
"detail": relationship.get("description") or "",
}
for relationship in relationships
]})
threads = model.open_threads(document)
if threads:
groups.append({"title": "Open Story Threads", "rows": [
{
"key": key,
"label": thread.get("title") or key,
"detail": thread.get("description") or "",
}
for key, thread in sorted(threads.items())
]})
return {"groups": groups, "empty": not groups}
def _title_for(kind: str) -> str:
"""A heading for an entity category the campaign chose.
Pluralised generically rather than from a table, because the categories are
open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would
silently mislabel the tenth.
"""
known = {
"character": "Characters",
"location": "Locations",
"organization": "Organizations",
"item": "Items",
"vehicle": "Vehicles",
"creature": "Creatures",
"structure": "Structures",
"concept": "Concepts",
"other": "Other",
}
if kind in known:
return known[kind]
word = kind.replace("_", " ").strip().title()
return word if word.endswith("s") else word + "s"
def _source_label(fact: dict) -> str:
source = fact.get("authority") or fact.get("source") or ""
return {
"manual_correction": "your correction",
"campaign_canon": "campaign canon",
"accepted_story": "from the story",
}.get(source, str(source).replace("_", " "))
+195
View File
@@ -0,0 +1,195 @@
"""M5: writing accepted state, atomically with the turn that caused it.
This is the only module in the package that touches the database, and the only
place authoritative narrative state is written.
## The atomicity rule (L01)
Everything a turn establishes goes in one transaction: the narration, the head
movement, the accepted events, the resulting snapshot, and the provenance. This
function *adds* to the caller's session and never commits — the turn engine's
single `db.commit()` remains the one commit point, so a failure anywhere before
it rolls the whole turn back rather than leaving narration accepted with half its
state written.
That ordering is deliberate and load-bearing. `L01` forbids a head position that
implies an accepted reply whose state commit did not complete, and the cheapest
way to guarantee that is to never have two commits to get out of step.
## What is not here
No reconstruction. Nothing in this module reads `state_events` to rebuild a
document — the snapshot on the node is the restore path
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
trail that the system depends on for correctness stops being an audit trail and
becomes a replay engine.
"""
from __future__ import annotations
import copy
from sqlalchemy.orm import Session
from .. import models
from . import apply as apply_module
from . import model
def current(adventure: models.Adventure) -> dict:
"""The campaign's authoritative state right now, as a document.
Normalised on the way out, so every caller gets the same shape whatever a
hand-edited row or an older snapshot contains.
"""
return model.normalize(adventure.narrative_state)
def set_current(adventure: models.Adventure, state: dict) -> None:
adventure.narrative_state = model.normalize(state)
def canon_of(adventure: models.Adventure) -> dict:
"""The campaign's own rules, which outrank anything a narration proposes.
Configuration rather than code (C01, J03): the campaign says what it forbids,
and `validate` enforces it without knowing what the rule means.
"""
canon = adventure.campaign_canon
return canon if isinstance(canon, dict) else {}
def record(
db: Session,
adventure: models.Adventure,
*,
review,
raw_block: str = "",
parsed=None,
action: models.Action | None = None,
branch_id: int | None = None,
depth: int | None = None,
model_name: str = "",
source: str = "accepted_story",
) -> tuple[dict, models.StateProposal]:
"""Applies a reviewed proposal and records everything about it.
Returns `(new_state, proposal_row)`. The caller is responsible for putting
the new state where it belongs — on the campaign, and on the node's snapshot
— because only the caller knows whether this is a turn, a retry or a
correction.
Nothing is committed here. See the module docstring.
"""
before = current(adventure)
after = apply_module.apply_events(
before, review.accepted, branch_id=branch_id, depth=depth, source=source
)
proposal = models.StateProposal(
adventure_id=adventure.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
model_name=model_name or "",
source=source,
status=review.status,
raw_output=raw_block or "",
detail={
"parsed": parsed,
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
},
)
db.add(proposal)
# The proposal needs an id before its events can point at it, and the
# session does not autoflush. This is a flush, not a commit: still one
# transaction, still all-or-nothing.
db.flush()
for sequence, event in enumerate(review.accepted):
db.add(models.StateEvent(
adventure_id=adventure.id,
proposal_id=proposal.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
sequence=sequence,
event_type=event.get("type", ""),
payload=copy.deepcopy(event),
before=_before_value(before, event),
source=source,
))
return after, proposal
def _before_value(state: dict, event: dict) -> dict | None:
"""What the value this event changes was, immediately beforehand.
Recorded per event so §8's "what was the previous value" is answerable
without replaying anything. Only the slice the event touches: a whole
document per event would duplicate the snapshot for no extra answer.
"""
kind = event.get("type")
if kind in ("set_entity_status", "set_entity_attribute",
"set_entity_conditions", "set_current_location"):
entity = model.entity(state, event.get("entity", ""))
if entity is None:
return None
if kind == "set_entity_status":
return {"status": entity.get("status")}
if kind == "set_entity_attribute":
attribute = event.get("attribute")
return {"attribute": attribute,
"value": (entity.get("attributes") or {}).get(attribute)}
if kind == "set_entity_conditions":
return {"conditions": list(entity.get("conditions") or [])}
return {"location": entity.get("location")}
if kind in ("set_possession", "clear_possession"):
return {"owner": model.owner_of(state, event.get("item", ""))}
if kind == "invalidate_fact":
for fact in state.get("facts") or []:
if fact.get("id") == event.get("fact_id"):
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
return None
if kind == "resolve_story_thread":
thread = (state.get("threads") or {}).get(event.get("thread", ""))
return {"status": thread.get("status")} if isinstance(thread, dict) else None
if kind == "end_relationship":
return {"status": "active"}
return None
# ------------------------------------------------------------------ reading
def events_for(
db: Session, adventure: models.Adventure, action_id: int
) -> list[models.StateEvent]:
"""The accepted events one node's narration produced, in order."""
return (
db.query(models.StateEvent)
.filter(
models.StateEvent.adventure_id == adventure.id,
models.StateEvent.action_id == action_id,
)
.order_by(models.StateEvent.sequence, models.StateEvent.id)
.all()
)
def history(
db: Session, adventure: models.Adventure, limit: int = 200
) -> list[models.StateEvent]:
"""The campaign's accepted state events, newest first.
Bounded by default: this is an audit view, and an unbounded read of a long
campaign's every event is the kind of query this project keeps a regression
test about.
"""
return (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adventure.id)
.order_by(models.StateEvent.id.desc())
.limit(limit)
.all()
)
+317
View File
@@ -0,0 +1,317 @@
"""M5: deciding which proposed events the application will accept.
A proposal is untrusted model output. This module is the gate between it and the
authoritative state, and it is layered so that a rejection can say *which* rule
refused and a test can aim at one layer at a time:
1. envelope is this a proposal at all — a dict with a list of events?
2. allowlist is each event type one this application implements? (H05)
3. schema are the required fields present, and the right shape?
4. referential do the entities and threads it names exist?
5. semantic does it contradict campaign canon, or itself?
Layer 2 is the security boundary and runs before any field is read, so a payload
carrying `command` or `path` alongside an unknown type is discarded without those
fields ever being looked at.
## What rejection means
Nothing is partially applied. `review` returns accepted and rejected events
separately and the caller decides; `apply.py` is only ever handed the accepted
list. A proposal with one bad event out of four therefore lands three, which is
`partially_accepted` — the alternative, discarding all four because the model
misspelled one entity, loses story the user watched happen.
What is *never* allowed is a rejected event mutating anything, or a rejection
being silent: every refusal carries a reason, is counted, and is stored on the
proposal record for §8's audit.
## What this module does not do
It does not decide whether the model was *right*. A typed event can be
well-formed, reference real entities, contradict nothing, and still describe
something the narration did not say. That is C06's territory and no validator
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
proposal is wrong in a way a person can see in the audit trail, rather than one
that silently means something other than it appears to.
"""
from __future__ import annotations
from . import events, model
# A rejected event carries one of these, so tests and the debug view can assert
# on the reason rather than on prose.
UNKNOWN_TYPE = "unknown_event_type"
NOT_AN_OBJECT = "not_an_object"
MISSING_FIELD = "missing_field"
BAD_FIELD_TYPE = "bad_field_type"
UNKNOWN_REFERENCE = "unknown_reference"
CANON_CONFLICT = "canon_conflict"
SELF_CONTRADICTION = "self_contradiction"
DUPLICATE_ENTITY = "duplicate_entity"
# How many events one proposal may carry. A narration describes a turn, not a
# migration; a hundred events is a runaway model or a payload trying to be
# something else, and either way the cap bounds the work before it is done.
MAX_EVENTS = 40
# How long a text field may be. Long enough for a description, short enough that
# a proposal cannot smuggle a document into the state.
MAX_TEXT = 2_000
MAX_LABELS = 40
class Rejection:
"""One event that will not be applied, and why."""
__slots__ = ("event", "reason", "detail")
def __init__(self, event, reason: str, detail: str = ""):
self.event = event
self.reason = reason
self.detail = detail
def as_dict(self) -> dict:
return {"event": self.event, "reason": self.reason, "detail": self.detail}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Rejection {self.reason}: {self.detail}>"
class Review:
"""The verdict on one proposal."""
__slots__ = ("accepted", "rejected")
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
self.accepted = accepted
self.rejected = rejected
@property
def status(self) -> str:
"""`DATA-MODEL.md` §19's validation_status."""
if self.rejected and self.accepted:
return "partially_accepted"
if self.rejected:
return "rejected"
return "accepted"
def as_dict(self) -> dict:
return {
"status": self.status,
"accepted": self.accepted,
"rejected": [r.as_dict() for r in self.rejected],
}
def review(payload, state: dict, canon: dict | None = None) -> Review:
"""Returns which of `payload`'s events may be applied to `state`.
`state` is the document the events would apply to, needed because
referential checks ask what already exists. `canon` carries the campaign's
own rules, which outrank anything a narration proposes (C01).
The state is **not** mutated. Events are checked against a running view that
accounts for entities earlier events in the same proposal create, so a
proposal may introduce Mara and then move her, but nothing is written until
the caller applies the accepted list.
"""
accepted: list[dict] = []
rejected: list[Rejection] = []
proposed = _events_of(payload)
if proposed is None:
return Review([], [Rejection(payload, NOT_AN_OBJECT,
"the proposal is not an object with an event list")])
# Entities this proposal has introduced, so a later event in the same
# proposal may refer to them. Kept separately from `state` so that a
# rejected create cannot make a later reference resolve.
introduced: set[str] = set()
for raw in proposed[:MAX_EVENTS]:
problem = _check(raw, state, introduced, canon)
if problem is not None:
rejected.append(problem)
continue
accepted.append(raw)
spec = events.spec(raw["type"])
if spec and spec["creates"]:
introduced.add(str(raw[spec["creates"]]))
for extra in proposed[MAX_EVENTS:]:
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
f"more than {MAX_EVENTS} events in one proposal"))
return Review(accepted, rejected)
def _events_of(payload) -> list | None:
"""The event list, from either shape a proposal may legitimately take."""
if isinstance(payload, list):
return [e for e in payload]
if not isinstance(payload, dict):
return None
found = payload.get("events")
if found is None:
return []
if not isinstance(found, list):
return None
return found
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
"""Returns why `raw` is unacceptable, or None if it may be applied."""
# ---- layer 1: is it an event-shaped object at all ----
if not isinstance(raw, dict):
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
# ---- layer 2: the allowlist, before any field is read ----
#
# H05 lands here. `execute_shell` is refused because it is not in the
# vocabulary, and its `command` field is never looked at — there is no
# branch in this application that could reach it.
event_type = raw.get("type", raw.get("event_type"))
if not events.is_allowed(event_type):
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
raw["type"] = event_type
spec = events.spec(event_type)
# ---- layer 3: schema ----
for field, kind in spec["required"].items():
if field not in raw:
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
for field, kind in spec["optional"].items():
if field in raw and raw[field] is not None:
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
# ---- layer 4: referential integrity ----
known = set(state.get("entities") or {}) | introduced
for field in spec["refs"]:
named = raw.get(field)
if named is None or field not in raw:
continue # optional reference, absent
if not isinstance(named, str) or named not in known:
return Rejection(raw, UNKNOWN_REFERENCE,
f"{event_type} names {field}={named!r}, which does not exist")
if event_type == "add_fact" and raw.get("object") is not None:
# An object may name an entity or another fact. Checking both keeps the
# reference meaningful — a typo is still caught — without forcing every
# thing a fact can be about to be promoted to an entity first.
known_facts = {f.get("id") for f in (state.get("facts") or [])}
target = raw["object"]
if not isinstance(target, str) or (target not in known and target not in known_facts):
return Rejection(raw, UNKNOWN_REFERENCE,
f"add_fact names object={target!r}, which does not exist")
if event_type == "invalidate_fact":
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no fact {raw['fact_id']!r} to invalidate")
if event_type == "resolve_story_thread":
if raw["thread"] not in (state.get("threads") or {}):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no story thread {raw['thread']!r} to resolve")
if spec["creates"]:
key = raw[spec["creates"]]
if key in known:
return Rejection(raw, DUPLICATE_ENTITY,
f"{key!r} already exists; use set_* to change it")
# ---- layer 5: semantics ----
return _semantic(raw, state, canon)
def _bad_shape(value, kind: str, field: str) -> str | None:
"""Returns why `value` is the wrong shape for `kind`, or None."""
if kind in (events.TEXT, events.KEY):
if not isinstance(value, str) or not value.strip():
return f"{field!r} must be a non-empty string"
if len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.VALUE:
# A scalar. Explicitly not a dict or a list: a nested payload is how a
# value field becomes somewhere to hide a second protocol.
if not isinstance(value, (str, int, float, bool)) and value is not None:
return f"{field!r} must be a plain value, not a structure"
if isinstance(value, str) and len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.LABELS:
if not isinstance(value, list):
return f"{field!r} must be a list"
if len(value) > MAX_LABELS:
return f"{field!r} has more than {MAX_LABELS} entries"
for item in value:
if not isinstance(item, str) or not item.strip():
return f"{field!r} must contain only non-empty strings"
if len(item) > MAX_TEXT:
return f"{field!r} contains an over-long entry"
return None
return f"{field!r} has an unknown field kind" # pragma: no cover
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
"""Deterministic checks the application can actually make.
Deliberately modest. ADR 010 is explicit that typed events do not make a
model correct, and pretending arbitrary fiction can be validated would be
worse than admitting it cannot: it would produce confident rejections of
perfectly good story. So this refuses only what the application *knows* is
wrong — a self-contradiction, or a collision with a rule the campaign wrote
down.
"""
event_type = raw["type"]
# An entity cannot hold itself, and cannot be in itself.
if event_type == "set_possession" and raw["item"] == raw["owner"]:
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
return Rejection(raw, SELF_CONTRADICTION,
"a relationship needs two different entities")
# C01: campaign canon outranks narration. The rule is generic — a campaign
# declares transitions it forbids, and any event proposing one is refused.
# Nothing here knows what any of those transitions mean; the campaign
# says which it forbids, in data.
conflict = _canon_conflict(raw, state, canon)
if conflict is not None:
return Rejection(raw, CANON_CONFLICT, conflict)
return None
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
"""Whether campaign canon forbids what this event proposes.
Canon is configuration, not code (J03). A campaign writes:
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
and a narration that tries to bring a dead character back is refused —
without this module, or any other, containing the word for what that is. A
science-fiction campaign forbidding a different transition uses the same
field and the same code path.
"""
if not isinstance(canon, dict):
return None
if raw["type"] != "set_entity_status":
return None
forbidden = canon.get("forbidden_status_changes")
if not isinstance(forbidden, list):
return None
current = (model.entity(state, raw["entity"]) or {}).get("status")
for rule in forbidden:
if not isinstance(rule, dict):
continue
if rule.get("from") == current and rule.get("to") == raw["status"]:
return (
f"campaign canon does not allow {raw['entity']!r} to go from "
f"{current!r} to {raw['status']!r}"
)
return None
@@ -13,6 +13,8 @@ Read the modules in this order to follow a turn from end to end:
turns playing a turn, and the lock that allows only one at a time
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:
@@ -31,6 +33,8 @@ from . import ( # noqa: F401
turns,
takes,
branches,
checkpoints,
state,
bundle_io,
refresh,
insights,
+179 -19
View File
@@ -8,7 +8,8 @@ coordinate through `nodes.delete_turn`.
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, head, models, schemas, tree
from ... import attempts, head, memorybank, models, narrative, schemas, tree
from ...context import cursors, lineage
from ...database import get_db
from . import turns
@@ -60,19 +61,20 @@ def update_action(
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# An edit rewrites this row and re-evaluates nothing after it, which is what
# makes it a correction rather than a new continuation. That is safe while
# everything descending from the row is on screen, and unsafe the moment
# something descends from it that is not — an undone future, or a line a
# divergence left behind. The reader cannot see that story, so they cannot
# see what their correction has just contradicted (M3).
#
# Refusing is the whole of the fix, deliberately. Making the edit fork, so
# that the original text and its future stay whole, is
# `STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
# state the edited prose implies, which is M5's extraction pass. Neither is
# started here. What is closed is the one case where the application could
# produce retained history that silently disagrees with itself.
# A narrator turn the story is currently telling is corrected through the
# §§14-15 path, which forks. A take the story is *not* telling is a
# different thing: it has no continuation of its own — keeping one is what
# forking is for — so correcting its words cannot contradict anything, and
# it stays the plain in-place edit it has always been.
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
return _edit_narration(db, adventure, action, payload.text)
# A player's own words. Editing one rewrites this row and re-evaluates
# nothing after it, which is what makes it a correction rather than a new
# continuation. That is safe while everything descending from the row is on
# screen, and unsafe the moment something descends from it that is not — an
# undone future, or a line a divergence left behind. The reader cannot see
# that story, so they cannot see what their correction has just contradicted
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
@@ -81,14 +83,172 @@ def update_action(
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
# One row holds one text. Nothing mirrors it now, so nothing else has to be
# updated. The edit used to have to be written into the live variant entry
# as well, or paging away and back reverted it.
action.text = payload.text
db.commit()
turns.acquire_turn_lock(adventure_id)
try:
action.text = payload.text
db.commit()
finally:
turns._active_turns.discard(adventure_id)
db.refresh(action)
return action
def _edit_narration(
db: Session, adventure: models.Adventure, action: models.Action, text: str
) -> models.Action:
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
A narrator edit is not a rewrite of a row. It is a continuation written from
the same place the original was written from, using the reader's words
instead of the model's. §15 lists what that has to mean, and each clause
maps to a step below:
1. return to the state immediately before the edited narration — the
preceding node's snapshot, one row read;
2. treat the edited text as the accepted narrator output — it is stored
verbatim, with only the protocol block stripped, and no model is called;
3. re-evaluate the state that output implies — the normal M5 extraction and
validation path, run against that starting state;
4. create a new active continuation — a new node, and the head on it;
5. retain the original narration and its future as disposable history —
nothing on the old line is written to at all.
The M5 review found the previous implementation failing 3-5 together: it
edited the row in place and rewound the campaign's live state to that
position while the head stayed at the tip, so the reader saw a full
transcript over a state document describing an earlier moment, and the
snapshots below the edit still described prose that no longer existed
(Finding 1). Forking is what fixes it, and no new machinery is needed to
fork — this function is the ⑂ path from `takes.py` with the reader's text in
place of a generated one.
Two shapes, chosen by whether anything was written after the turn:
at the tip the attempts of the turn are still leaves, so the
correction joins them as a sibling take and the
original is retained beside it in the pager;
anything below the story after the turn was written as a
continuation of the words that are there now, so it
keeps them: the correction leaves the path just
before the turn and the old line keeps its node, its
future, and its live flag.
The §14A refusal is gone from this path, and this is what replaces it. It
refused an in-place edit under an off-screen future because the edit would
silently change the words that story was written from. Nothing is changed
now — the off-screen future keeps the exact narration it descends from — so
the case that had to be refused is simply handled.
"""
if action.depth is None:
raise HTTPException(400, "That turn is not on the story you are reading.")
turns.acquire_turn_lock(adventure.id)
try:
# §15.2. The reader's words are the narration; a block they pasted in is
# protocol and is stripped before storage, exactly as a model's is.
prose, parsed, raw_block = narrative.extract.split(text)
# §15.1. Not the campaign's current state — the state this turn was
# played from. One row read, not a replay (ADR 012).
before = attempts.preceding(db, adventure, action)
starting_state = (
narrative.model.normalize(before.narrative_state_after)
if before is not None and isinstance(before.narrative_state_after, dict)
else narrative.model.empty()
)
corrected = models.Action(
adventure_id=adventure.id,
type="ai",
text=prose,
# No model was called, so there is no prompt to show for this node.
# In the sibling case the turn's assembled prompt moves to whichever
# attempt is live, which is what the Insights viewer reads; in the
# forked case the original keeps it, because the original is still
# the live node of its own line.
context_snapshot=None,
)
tip = db_tip(db, adventure)
# A turn the head rests on is not a leaf while a retained future
# descends from it, and `db_tip` reads the capped path and cannot see
# that future. Ask the head module as well (M3).
at_the_tip = (
tip is not None
and tip.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# §15.4-5 as a take. The original stays at this coordinate as a
# prior attempt, reachable through the pager, and the correction
# becomes the one the story tells.
attempts.hand_over_the_prompt(action, corrected)
attempts.add_attempt(db, adventure, action, corrected)
db.add(corrected)
# The words at this coordinate changed, so anything derived from
# them no longer describes the story.
memorybank.forget_node(db, adventure, action)
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
db.flush()
else:
# §15.4-5 as a branch. Nothing on the departed line is written to:
# the original node keeps its text, its live flag and every turn
# that was played after it.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
tree.place_action(db, adventure, corrected)
db.add(corrected)
db.flush()
# §15.3. The same validation path a generated turn takes, so a hand
# -typed event is no more trusted than a model's: the allowlist, the
# schema, the references and the canon all still apply.
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
starting_state,
narrative.store.canon_of(adventure),
)
# `record` writes the events and the provenance. Its returned document
# applies them to the campaign's *current* state, which is not what an
# edit derives from, so the document this node leaves behind is computed
# from the turn's own starting point below.
narrative.store.record(
db, adventure,
review=review,
raw_block=raw_block,
parsed=parsed,
action=corrected,
branch_id=corrected.branch_id,
depth=corrected.depth,
source="narrator_edit",
)
new_state = narrative.apply.apply_events(
starting_state, review.accepted,
branch_id=corrected.branch_id, depth=corrected.depth,
source="narrator_edit",
)
corrected.state_changes = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"summary": narrative.apply.diff(starting_state, new_state),
}
# The head is on the corrected node, so the campaign's live state is
# what that node leaves behind, and the node's own snapshot is the same
# document. That equality is the invariant the review found broken:
# visible position == head == authoritative state.
narrative.store.set_current(adventure, new_state)
attempts.snapshot_outcome(adventure, corrected)
adventure.updated_at = models.utcnow()
db.commit()
finally:
turns._active_turns.discard(adventure.id)
db.refresh(corrected)
return corrected
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
+90 -6
View File
@@ -56,6 +56,19 @@ def list_branches(
.group_by(models.Action.branch_id)
.all()
}
# M4 closeout: how many Save Points name a position on each line. Deleting a
# branch deletes them along with its story, and the panel has to be able to
# say so before the button is pressed (review §R B-2). One grouped query for
# the whole tree, like the one above it — never one per branch.
save_points = {
branch_id: count
for branch_id, count in db.query(
models.Checkpoint.branch_id, func.count(models.Checkpoint.id)
)
.filter(models.Checkpoint.adventure_id == adventure.id)
.group_by(models.Checkpoint.branch_id)
.all()
}
out = []
for branch in branches:
count, tip = owned.get(branch.id, (0, None))
@@ -70,6 +83,7 @@ def list_branches(
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
),
own_actions=count,
save_points=save_points.get(branch.id, 0),
is_head=(branch.id == adventure.head_branch_id),
name=branch.name,
created_at=branch.created_at,
@@ -151,15 +165,31 @@ def delete_branch(
heavily retried adventure from growing without bound. That is why it ships
with the view that first lets anyone create a fork rather than after it.
Two kinds of branch cannot be deleted. The root cannot, because it holds the
turns every other branch borrows, so deleting it deletes the whole story. The
branch currently being read cannot, and neither can any branch it was forked
from, because the cascade would remove the head under the player and leave
`head_branch_id` dangling. Switch branches first.
Three kinds of branch cannot be deleted. The root cannot, because it holds
the turns every other branch borrows, so deleting it deletes the whole story.
The branch currently being read cannot, and neither can any branch it was
forked from, because the cascade would remove the head under the player and
leave `head_branch_id` dangling. Switch branches first.
The third is M4's: **a branch a Save Point names cannot be deleted while that
Save Point exists.** `STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint
remains until explicitly deleted, and §28 says a future cleanup feature must
retain paths referenced by checkpoints. A cascade that removed Save Points
along with a branch would break both, and would break them silently: the
story the user asked to delete is the visible thing, and the named moments
would go without ever being named in the request. So the deletion is refused,
the Save Points are listed, and the user decides — delete the Save Point
first, then the branch. Deleting a Save Point still deletes no story (§25),
so the recovery costs nothing but a click.
The check covers the whole doomed subtree, not just this branch, because
deleting a branch takes everything forked from it.
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
cascade on `branches.parent_branch_id`, so the delete is a single statement
however deep the subtree is.
however deep the subtree is. `checkpoints.branch_id` also carries a cascade,
as referential integrity — a Save Point must never point at a branch that is
gone — but the guard above means it does not fire through this endpoint.
"""
branch = get_branch_or_404(adventure, branch_id, db)
if branch.parent_branch_id is None:
@@ -178,6 +208,11 @@ def delete_branch(
400, "You are reading this branch, or one forked from it. Switch to "
"another branch first.",
)
# Refused before the lock is taken: this is a decision about the request, not
# a race with a turn.
protecting = _save_points_protecting(db, adventure, branch)
if protecting:
raise HTTPException(409, _protected_message(protecting))
turns.acquire_turn_lock(adventure_id)
try:
# Collect the subtree before the delete, because afterwards there is no
@@ -199,6 +234,55 @@ def delete_branch(
finally:
turns._active_turns.discard(adventure_id)
# How many Save Point names to spell out before the message starts summarising.
# Enough to be actionable, few enough to stay a sentence.
NAMED_IN_REFUSAL = 3
def _save_points_protecting(
db: Session, adventure: models.Adventure, branch: models.Branch
) -> list[models.Checkpoint]:
"""Returns the Save Points that deleting `branch` would destroy.
The whole subtree, because deleting a branch takes everything forked from
it, and a check that looked only at this branch would let a Save Point on a
child be deleted without a word.
"""
doomed = _branch_subtree(db, adventure, branch)
return (
db.query(models.Checkpoint)
.filter(
models.Checkpoint.adventure_id == adventure.id,
models.Checkpoint.branch_id.in_(doomed),
)
.order_by(models.Checkpoint.created_at, models.Checkpoint.id)
.all()
)
def _protected_message(protecting: list[models.Checkpoint]) -> str:
"""Says which Save Points stand in the way, and what to do about it.
Named rather than counted, because "2 Save Points" leaves the user hunting
for which ones. A long list is truncated so the message stays readable; the
Save Points panel shows the rest.
"""
names = [f"“{c.name}”" for c in protecting[:NAMED_IN_REFUSAL]]
listed = ", ".join(names)
extra = len(protecting) - len(names)
if extra > 0:
listed += f" and {extra} more"
subject = "a Save Point" if len(protecting) == 1 else "Save Points"
return (
f"This branch, or a branch forked from it, is where {subject} "
f"{listed} {'is' if len(protecting) == 1 else 'are'} saved. Delete "
f"{'that Save Point' if len(protecting) == 1 else 'those Save Points'} "
f"first if you no longer need "
f"{'it' if len(protecting) == 1 else 'them'}, then delete the branch. "
f"Deleting a Save Point does not delete any story."
)
def _branch_subtree(
db: Session, adventure: models.Adventure, root: models.Branch
) -> set[int]:
@@ -0,0 +1,351 @@
"""M4: Save Points — create, list, rename, delete, and restore.
A Save Point is a durable named pointer to a story position and nothing else.
It stores a coordinate, never a copy of any story, and restoring one moves the
active head to that coordinate. That is the whole design, and it is what
`BUILD-MILESTONES.md`'s note on M4 and ADR 012 ask for: M3 made the head a
stored `(branch, depth)` and made arriving at one a row lookup plus a state
restore, so a Save Point needs no restore machinery of its own.
What is deliberately absent from this module, because a second copy of any of it
would be the failure M4 is warned about:
* no head fields are assigned here — `head.move_to_node` moves the head, and
`head.move_to` under it restores the state, exactly as Undo and Redo do;
* nothing reconstructs state, prunes a memory, copies a turn, or deletes one;
* nothing forks. Restore is not a decision to abandon anything, so it creates no
branch. The first write below the restored head forks, through the same
`fork_if_behind_head` every other write goes through, and the displaced future
stays retained (`STORY-BRANCH-SEMANTICS.md` §20).
The user-facing word is "Save Point" and the internal one is `checkpoint`
(`BROWSER-UX-SPEC.md` §23). Error strings here are read by a player, so they say
Save Point.
"""
from dataclasses import dataclass
from fastapi import Depends, HTTPException
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ... import head, models, schemas
from ...context import lineage
from ...database import get_db
from . import turns
from .deps import current_adventure, router
from .paging import current_window
def _node_at(
db: Session, adventure: models.Adventure, branch_id: int, depth: int
) -> models.Action | None:
"""Returns the live turn a Save Point's coordinate names, or None.
The lookup is by coordinate and is not scoped to any path. That is the
point of it: a Save Point outlives the reader moving away, so the question
it has to answer is "is this position still in this campaign's retained
history", not "is it on the story being read now". Whether it is on the
current path is a separate question, and `head.move_to_node` is what acts on
the answer.
`live` is what makes the coordinate follow a retry. One coordinate can hold
several attempts at a turn, and a Save Point names the turn rather than the
attempt, so it lands on whichever take the story currently tells.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch_id,
models.Action.depth == depth,
models.Action.live.is_(True),
)
.order_by(models.Action.id)
.first()
)
@dataclass(frozen=True)
class _Coordinate:
"""The shape `lineage.Path` reads, without loading a story row.
`Path.contains` asks three things of a node: its branch, its depth, and
whether it is live. A coordinate already known to resolve has all three, so
the membership question can be put to the coordinate itself. That keeps the
single implementation of "is this on the path being read" in `lineage`,
where M3 put it, while costing no query and no prose.
"""
branch_id: int
depth: int
live: bool = True
def _live_coordinates(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> set[tuple[int, int]]:
"""Returns which of these Save Points' coordinates still name a live turn.
One query for the whole list, selecting two integer columns.
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
query each and loaded whole `Action` entities — narration included — to
answer a question that is only ever "does a row exist here". `paging.py`
states the rule this now follows: a bulk read names the columns it needs, so
a new column costs nothing until someone adds it to the list.
The clause is an OR of exact `(branch, depth)` pairs rather than
`branch IN (…) AND depth IN (…)`, which would match the cross product and
report a Save Point as resolved because *some other* Save Point's depth
exists on *this* one's branch.
"""
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
if not coordinates:
return set()
rows = (
db.query(models.Action.branch_id, models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.live.is_(True),
or_(*[
and_(models.Action.branch_id == branch, models.Action.depth == depth)
for branch, depth in coordinates
]),
)
.all()
)
return {(branch, depth) for branch, depth in rows}
def _render_all(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> list[schemas.CheckpointOut]:
"""Reads Save Points out with the three facts the panel needs about them.
Bounded work whatever the length of the list: one query for the coordinates
and one lineage for the campaign, both computed before the loop. Rendering
one Save Point and rendering fifty differ in Python, not in round trips.
"""
live = _live_coordinates(db, adventure, checkpoints)
# The path is a property of the campaign, not of any Save Point, so it is
# read once. Reading it per row was the other half of the N+1.
path = lineage.path_of(db, adventure).uncapped()
out = []
for checkpoint in checkpoints:
coordinate = (checkpoint.branch_id, checkpoint.depth)
resolved = coordinate in live
rendered = schemas.CheckpointOut.model_validate(checkpoint)
# The same `depth + 1` the branch list counts with, so a moment number
# means the same thing in both places.
rendered.turn = checkpoint.depth + 1
rendered.resolved = resolved
rendered.on_path = resolved and path.contains(
_Coordinate(checkpoint.branch_id, checkpoint.depth)
)
out.append(rendered)
return out
def _rendered(
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
) -> schemas.CheckpointOut:
"""Reads one Save Point out, through the same path the list uses."""
return _render_all(db, adventure, [checkpoint])[0]
def _get_or_404(
db: Session, adventure: models.Adventure, checkpoint_id: int
) -> models.Checkpoint:
"""Resolves a Save Point id, refusing one that belongs to another campaign.
The ownership check is the reason this is a function rather than a `db.get`
at each call site. A Save Point names a position in one campaign's history,
and a coordinate from another campaign would name a different story's turn —
or, worse, resolve against this one by arithmetic coincidence. So the id is
matched against this adventure, and a Save Point belonging to another is a
404 rather than a restore of the wrong story.
"""
checkpoint = db.get(models.Checkpoint, checkpoint_id)
if checkpoint is None or checkpoint.adventure_id != adventure.id:
raise HTTPException(404, "Save Point not found")
return checkpoint
def _clean_name(raw: str) -> str:
"""Returns the trimmed name, refusing one that is blank once trimmed."""
name = (raw or "").strip()
if not name:
raise HTTPException(400, "A Save Point needs a name.")
return name
@router.get("/{adventure_id}/checkpoints", response_model=list[schemas.CheckpointOut])
def list_checkpoints(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the campaign's Save Points, newest first.
Newest first rather than in story order, because story order is not
something this list can honestly claim. Depths are positions along a path,
and two Save Points on lines that parted company are not comparable by depth
at all — ordering by it would draw a sequence that no reading of the story
passes through. When they were made is a fact about all of them.
"""
rows = (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
.all()
)
return _render_all(db, adventure, rows)
@router.post(
"/{adventure_id}/checkpoints",
response_model=schemas.CheckpointOut,
status_code=201,
)
def create_checkpoint(
adventure_id: int,
payload: schemas.CheckpointCreate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Names the position the story is currently being read at.
The active head, not the retained tip. Creating a Save Point after two Undos
saves the undone position, because that is where the reader is and the
position they are looking at is the one they mean. The distinction only
exists at all because M3 stopped Undo from deleting.
The node at the head is resolved before the row is written, and its own
branch is what gets stored — which is not always the branch being read. A
head resting in a shared prefix sits on an ancestor's node, and the
ancestor is the branch that still names that position after the reader has
forked away from it.
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
where I am" has to name one committed position, and the head is exactly what
a turn in flight is about to move. Without the lock this endpoint could read
`head_depth` while a turn was mid-commit and store a coordinate for a
position the story had already left — a Save Point silently naming the wrong
moment, which no later operation could detect. It is the same lock Undo,
Redo and Restore take, for the same reason, and not a new mechanism.
Rename and Delete deliberately do **not** take it: neither reads nor moves a
story position, so there is nothing for a turn in flight to race them over.
"""
name = _clean_name(payload.name)
turns.acquire_turn_lock(adventure_id)
try:
node = head.node_at(db, adventure, adventure.head_depth)
if node is None:
raise HTTPException(400, "There is no turn here to save yet.")
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=name,
note=payload.note or "",
branch_id=node.branch_id,
depth=node.depth,
)
db.add(checkpoint)
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
finally:
turns._active_turns.discard(adventure_id)
@router.patch(
"/{adventure_id}/checkpoints/{checkpoint_id}",
response_model=schemas.CheckpointOut,
)
def rename_checkpoint(
checkpoint_id: int,
payload: schemas.CheckpointRename,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Changes a Save Point's label. Nothing else about it moves.
Not the coordinate, not the head, not a row of story. A Save Point that has
been renamed restores to exactly the position it did before, which is
`STORY-BRANCH-SEMANTICS.md` §23.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
if payload.name is not None:
checkpoint.name = _clean_name(payload.name)
if payload.note is not None:
checkpoint.note = payload.note
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
@router.delete("/{adventure_id}/checkpoints/{checkpoint_id}", status_code=204)
def delete_checkpoint(
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Removes the named pointer, and only the pointer.
The turn it named stays, its branch stays, the future past it stays, and the
head does not move. This endpoint deletes one row of the `checkpoints`
table. `STORY-BRANCH-SEMANTICS.md` §25.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
db.delete(checkpoint)
db.commit()
return None
@router.post(
"/{adventure_id}/checkpoints/{checkpoint_id}/restore",
response_model=schemas.ActionPage,
)
def restore_checkpoint(
adventure_id: int,
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the story to a Save Point, deleting nothing.
Four steps, and the last one is not this module's code: resolve the
coordinate, refuse it if it no longer names a live turn, hand it to
`head.move_to_node`, and answer with the window the head now caps. The
transcript, the world state, the assembled context and which memories can be
retrieved all move together, because all four already read through the one
path object the head caps — the same reason Undo needed no memory pruning.
The turns past the restored position are retained, exactly as they are after
an Undo, and ordinary Redo can still walk forward into them until the user
writes something different. Restore does not fork; the first write below the
head does.
A coordinate that no longer resolves is refused rather than approximated.
Moving the head to the nearest surviving turn would be the one outcome worse
than doing nothing: a Save Point that silently means somewhere else.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
turns.acquire_turn_lock(adventure_id)
try:
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
if node is None:
raise HTTPException(
409,
"That Save Point's position is no longer part of this story.",
)
head.move_to_node(db, adventure, node)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# A window, not the whole story, for the reason Undo gives: the client
# replaces its transcript with this, and the transcript is a window.
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)
+7
View File
@@ -28,6 +28,13 @@ ACTION_LIST_COLUMNS = (
models.Action.text,
models.Action.reasoning,
models.Action.world_delta,
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
# above it. `ActionOut.state_summary` reads it for every row on the page, so
# leaving it out of the bulk read cost one lazy load per action — 51 rows
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
# events and its summary lines, the same order of size as `world_delta`, not
# the deferred snapshot.
models.Action.state_changes,
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
# would cost a lazy load, which is the cost `load_only` is here to prevent.
# `branch_id` is listed for the same reason. The pager reads it to tell a
+173
View File
@@ -0,0 +1,173 @@
"""M5: reading the authoritative narrative state, and correcting it by hand.
Three endpoints, and the split between them is the point:
GET /state what the campaign currently believes
POST /state/corrections the user overruling it (C04)
GET /state/events how it came to believe that (§8's audit)
The browser reads the first and writes the second. It never writes state
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
presentation layer and must not become the owner of state — so a correction goes
through the same validator, the same applier and the same event log as a
narration does. The only difference is the `source` recorded on it, and that
difference is the whole of C04's audit requirement.
The state returned here is always the state at the **active head**, because that
is what `adventure.narrative_state` holds: head movement restores it from the
destination node's snapshot, so an undone story is described by what was true
then rather than by what the campaign later became.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import head, models, narrative, schemas
from ...database import get_db
from . import turns
from .deps import current_adventure, router
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
def read_state(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The authoritative state at the position the story is being read at.
Grouped for display, with only the categories that actually hold something —
a heading with no rows under it tells a reader nothing, and the panel should
not have to decide what to hide.
"""
state = narrative.store.current(adventure)
view = narrative.render.for_inspector(state)
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
# The raw document, for the correction form to name a key with and for a
# test to assert on without parsing prose.
document=state,
)
@router.post(
"/{adventure_id}/state/corrections",
response_model=schemas.NarrativeStateOut,
status_code=201,
)
def correct_state(
adventure_id: int,
payload: schemas.StateCorrection,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Applies the user's own state events, as an explicit correction.
C04. The user says "Mara never learned where the silver key was found", and
that becomes authoritative for everything that follows — while the transcript
stays exactly as it was written. Correcting the world is not editing the
story, and conflating them would rewrite prose the user did not ask to
change.
The events go through the **same validator** as a narration's. A user is
trusted more than a model, but not with references that do not resolve or
with an event type the application does not implement: a typo should be a
clear refusal, not a corrupt document. What being trusted buys is authority —
the resulting facts carry `manual_correction`, which outranks
`accepted_story` when the two disagree, and which the prompt renders so the
model is told the reader overruled it.
Held under the turn lock, for the reason creating a Save Point is: this reads
the head and writes a snapshot onto the node the head rests on, and a turn in
flight is about to move both.
"""
if not payload.events:
raise HTTPException(400, "A correction needs at least one change.")
turns.acquire_turn_lock(adventure_id)
try:
state = narrative.store.current(adventure)
review = narrative.validate.review(
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
state,
narrative.store.canon_of(adventure),
)
if not review.accepted:
raise HTTPException(400, _refusal_message(review))
node = head.node_at(db, adventure, adventure.head_depth)
new_state, _proposal = narrative.store.record(
db, adventure,
review=review,
raw_block=payload.note or "",
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
action=node,
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
depth=node.depth if node is not None else adventure.head_depth,
source="manual_correction",
)
narrative.store.set_current(adventure, new_state)
# The correction belongs to the position it was made at, so a later Undo
# past it drops it and a Redo back brings it again — the same rule every
# other state change follows. Without re-snapshotting the node, the
# correction would survive a head movement that stepped over it.
if node is not None:
node.narrative_state_after = new_state
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
finally:
turns._active_turns.discard(adventure_id)
view = narrative.render.for_inspector(narrative.store.current(adventure))
return schemas.NarrativeStateOut(
groups=[schemas.StateGroup(**group) for group in view["groups"]],
empty=view["empty"],
document=narrative.store.current(adventure),
)
def _refusal_message(review) -> str:
"""Why a correction was refused, in the words the user needs.
The first rejection's detail, because a correction is usually one or two
events and a wall of them helps nobody.
"""
if review.rejected:
first = review.rejected[0]
return f"That correction can't be applied — {first.detail or first.reason}."
return "That correction can't be applied."
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
def read_state_events(
limit: int = 100,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""The accepted state changes, newest first: §8's audit trail.
What changed, which turn caused it, whether the model or the user asserted
it, and what the value was before. Bounded by default — this is an audit
view, and an unbounded read of a long campaign's every event is the query
shape this project keeps a regression test about.
"""
limit = max(1, min(limit, 500))
rows = narrative.store.history(db, adventure, limit=limit)
return [
schemas.StateEventOut(
id=row.id,
action_id=row.action_id,
branch_id=row.branch_id,
depth=row.depth,
turn=(row.depth + 1) if row.depth is not None else None,
sequence=row.sequence,
event_type=row.event_type,
payload=row.payload or {},
before=row.before,
source=row.source,
created_at=row.created_at,
)
for row in rows
]
+67 -19
View File
@@ -13,7 +13,8 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ... import (
attempts, head, limits, memorybank, models, schemas, tree, worldstate,
attempts, head, limits, memorybank, models, narrative, schemas, tree,
worldstate,
)
from ...context import build_context, cursors
from ...database import get_db
@@ -207,25 +208,46 @@ async def _generate_turn(
yield turn_error(detail)
return
# RPG world state (Phase 12): read the AI's state delta out of the reply,
# apply it through the engine, and strip the block from the displayed text.
# M5: read the typed state proposal out of the reply, validate it, apply
# what survives, and strip the block from the displayed text.
#
# A retry re-runs the same turn, so it is played at that turn's depth. The
# cooldown rules run on a position in the story, and a second attempt at turn
# 12 is still turn 12. This was `retry_of.index`, which held the same number
# until SP4. Depth stays correct once a branch has its own numbering.
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
# protocol, not the lifecycle. What changed is that the referee now works on
# explicit typed events with absolute values, so an accepted proposal cannot
# mean something other than it says.
#
# A retry re-runs the same turn, so it is played at that turn's depth. This
# was `retry_of.index`, which held the same number until SP4. Depth stays
# correct once a branch has its own numbering.
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
if worldstate.has_schema(stat_schema):
text, delta = worldstate.extract_delta(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
new_world_state, ws_report = worldstate.apply_delta(
adventure.world_state, stat_schema, delta, ai_depth
)
adventure.world_state = new_world_state
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
text, parsed, raw_block = narrative.extract.split(text)
if not text.strip():
yield turn_error("The AI returned only a state update and no story text.")
return
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
narrative.store.current(adventure),
narrative.store.canon_of(adventure),
)
# Held until the action exists, because a proposal record names the node
# whose narration produced it and the node has no id yet. Everything lands
# in the single commit below (L01).
# The coordinate is read off the node after it is placed, not guessed here:
# `tree.place_action` assigns the branch, and a retry inherits the branch of
# the attempt it replaces.
pending_state = {
"review": review,
"parsed": parsed,
"raw_block": raw_block,
"unparseable": parsed is None and bool(raw_block),
}
snapshot["narrative_state"] = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"status": review.status,
}
snapshot["raw_output"] = raw_output
# The cost the endpoint reports for the call, including how much of the
@@ -243,7 +265,6 @@ async def _generate_turn(
context_snapshot=snapshot,
world_delta=world_delta_of(snapshot),
)
attempts.snapshot_outcome(adventure, ai_action)
if retry_of is not None:
attempts.add_attempt(db, adventure, retry_of, ai_action)
db.add(ai_action)
@@ -264,6 +285,33 @@ async def _generate_turn(
else:
tree.place_action(db, adventure, ai_action)
db.add(ai_action)
db.flush()
# The state lands after the node exists and before the one commit, so the
# narration, the head, the accepted events, the provenance and the snapshot
# are one transaction. L01 forbids any window in which a turn looks accepted
# while its state is half-written, and the cheapest guarantee is to have a
# single commit rather than two that could get out of step.
new_state, _proposal = narrative.store.record(
db, adventure,
review=pending_state["review"],
raw_block=pending_state["raw_block"],
parsed=pending_state["parsed"],
action=ai_action,
branch_id=ai_action.branch_id,
depth=ai_action.depth,
model_name=settings.model or "",
source="accepted_story",
)
if pending_state["unparseable"]:
_proposal.status = "unparseable"
before_state = narrative.store.current(adventure)
narrative.store.set_current(adventure, new_state)
ai_action.state_changes = {
"accepted": pending_state["review"].accepted,
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
"summary": narrative.apply.diff(before_state, new_state),
}
attempts.snapshot_outcome(adventure, ai_action)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(ai_action)
+148 -1
View File
@@ -25,6 +25,10 @@ ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
# M4: what a player called a Save Point. VARCHAR(120). Wider than a branch name
# because these are sentences rather than labels — "Before entering the abbey"
# is the example the specification uses throughout.
CHECKPOINT_NAME_MAX = 120
Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
@@ -35,6 +39,7 @@ Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
class ORMModel(BaseModel):
@@ -199,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
@@ -254,6 +264,10 @@ class BranchOut(ORMModel):
fork_depth: int | None = None
depth: int
own_actions: int = 0
# M4: how many Save Points name a position on this line. Deleting the branch
# deletes them with its story, so the panel warns with a number rather than
# a vague caution. Zero for a line nobody has bookmarked, which is most.
save_points: int = 0
is_head: bool = False
# NULL for a branch nobody has named. The client labels those from the fork
# depth rather than the server inventing a name. See the column comment.
@@ -267,6 +281,139 @@ 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
# (`BROWSER-UX-SPEC.md` §23). The wire format uses the internal name, as the
# rest of this module does.
class CheckpointOut(ORMModel):
"""One Save Point: a name and the position it names.
The position is reported three ways because the panel needs three different
things from it. `turn` is what a reader counts — the same `depth + 1` the
branch list shows. `depth` and `branch_id` are the coordinate itself.
`on_path` says whether the position lies on the story being read, which is
how the panel can tell a Save Point on this line from one naming a line the
story has left; restoring either works, but they are not the same offer.
`resolved` is false when the coordinate no longer names a live turn, which
an action deleted out of the middle of a story can do. Restore refuses such
a Save Point rather than moving the head somewhere approximate, so the list
says so before the button is pressed.
"""
id: int
adventure_id: int
name: str
note: str = ""
branch_id: int
depth: int
turn: int = 0
on_path: bool = True
resolved: bool = True
created_at: datetime
updated_at: datetime
class CheckpointCreate(BaseModel):
"""A Save Point at wherever the story is being read.
The position is not a field. A Save Point is made at the campaign's active
head, which the server already knows, and accepting a coordinate from the
client would be the second way to name a position — the thing this milestone
exists not to build.
"""
name: CheckpointName
note: Prose = ""
class CheckpointRename(BaseModel):
"""A new label, and nothing else.
There is deliberately no coordinate here. `STORY-BRANCH-SEMANTICS.md` §24
keeps a Save Point's meaning auditable by refusing to move one: rename it,
or delete it and make another where you are.
"""
name: CheckpointName | None = None
note: Prose | None = None
class ActionUpdate(BaseModel):
text: ActionText
+17
View File
@@ -348,6 +348,23 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
if action.world_state_after is None:
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
action.world_state_after = copy.deepcopy(world)
if action.narrative_state_after is None:
# M5, and the same rule: a node with no narrative snapshot is a position
# the head cannot be restored to, and the failure is silent — the state
# simply stays where it was. A campaign's opening node is written by the
# fixture that creates the adventure rather than by the turn engine, so
# without this it would be the one position Undo could not return to.
#
# An empty document rather than NULL, because this node is being written
# *now*, by a writer that knows the campaign has no state yet. That is
# different from a pre-M5 row, whose NULL means "there was no such thing
# as narrative state when this played" and must leave the live state
# alone.
from .narrative import model as narrative_model
narrative = adventure.narrative_state
action.narrative_state_after = copy.deepcopy(
narrative if isinstance(narrative, dict) else narrative_model.empty()
)
def place_new_nodes(session: Session) -> None:
+67
View File
@@ -0,0 +1,67 @@
"""The storyteller, run as a real OS process for `test_process_restart.py`.
Not a test module, and named so pytest does not collect it: it is the program
the test starts, twice, against one database file.
The model is replaced with a deterministic fake before the app is imported, so
the process needs no Ollama, no network and no configuration. Everything else —
the engine, the migrations, the routers, the session lifecycle — is the real
application, which is the whole point of spawning a process at all.
python _restart_server.py <db_path> <port>
"""
import itertools
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # backend/, so `app` imports
sys.path.insert(0, str(HERE)) # tests/, so `fakes` imports
db_path, port = sys.argv[1], int(sys.argv[2])
os.environ["AIDND_DB_PATH"] = db_path
# A developer's shell may point these at Postgres, and `app.database` prefers
# either over the SQLite path. The suite's conftest clears them for the same
# reason; a spawned process does not inherit that, so clear them here too.
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fakes import TALLY_PER_TURN, tally_reply # noqa: E402
_turn = itertools.count(1)
class DeterministicProvider:
"""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
test can assert rather than a paragraph it has to interpret. M5 replaces the
machinery underneath; what this measures is where the story is being read.
"""
last_usage = None
def __init__(self, *a, **k):
pass
async def generate(self, parts, *, temperature, max_tokens):
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
turns.OpenAICompatibleProvider = DeterministicProvider
from app.main import app # noqa: E402
if __name__ == "__main__":
import uvicorn
# Loopback only, as every supported start path does.
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
+101 -25
View File
@@ -5,6 +5,7 @@ their own `ScriptedProvider`, and the copies had drifted into four different
feature sets, so a test that needed to raise a provider error had to be written
in one of the files whose copy supported it.
"""
import json
class ScriptedProvider:
@@ -48,35 +49,110 @@ class ScriptedProvider:
# that a rollback failure is arithmetic rather than a judgement call: if a take
# stacks instead of replacing, the total is off by exactly one turn's worth.
#
# That instrumentation used to be a JavaScript `output` hook doing
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
# the tests below are not about scripting — they are about the state snapshot,
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
# which is unchanged.
# The instrument has moved twice, and both moves were the same move: it follows
# whatever the production state path is, so the tests exercise real code rather
# than a test hook. It began as a QuickJS `state.gold += 10` (removed with
# scripting in M2), became an RPG world-state delta block (M3/M4), and is now a
# typed narrative-state event (M5).
#
# The counter therefore moved to the world-state engine, which is a real
# remaining product path: the model emits a ```state delta block, the referee
# applies it, and the result lands in `adventure.world_state`. The fake
# provider decides what the model "emits", so it is exactly as deterministic as
# the script was, and it exercises production code rather than a test hook.
# What the tests using it measure is unchanged, and worth restating because it
# is why they were re-instrumented rather than deleted: the state at a story
# position, rollback, Redo restoration, retry, alternate takes, divergence,
# abandoned-future isolation, and Save Point restore. None of that was ever
# about gold, or about RPG stats.
#
# The M5 instrument is deliberately genre-neutral: a `chronicle` entity — a
# concept, not a character, not an item — carrying one named attribute. Every
# reply sets it to an ABSOLUTE total, which is ADR 010's whole point. A delta
# protocol could not tell "+10" from "= 10"; here the event type says which, so
# `TALLY_PER_TURN * n` after n turns is arithmetic rather than an assumption.
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
#: `cooldown`, so every +10 is applied in full, every turn.
GOLD_SCHEMA = {
"player": {
"hp": {"min": 0, "max": 100, "initial": 100},
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
}
}
#: The instrument is a fact, not an entity attribute, and deliberately so.
#: `set_entity_attribute` names an entity that must already exist, which is the
#: right rule for the product and the wrong one for an instrument that tests
#: script in isolation — a one-off reply in the middle of a test would be
#: refused for a reference the test never meant to be about. `add_fact` needs no
#: subject, so any reply can state the tally on its own. Entity creation,
#: possession and the referential rule get their own tests in
#: `test_narrative_state.py`, where they are the subject rather than scaffolding.
TALLY_PREDICATE = "tally"
TALLY_PER_TURN = 10
GOLD_PER_TURN = 10
# Kept as an alias so the many tests that speak in these terms keep reading
# naturally. The number is the same; only the protocol underneath changed.
GOLD_PER_TURN = TALLY_PER_TURN
#: A scenario schema is no longer needed for state to work — narrative state is
#: not an opt-in RPG layer. The name survives for fixtures that still pass
#: something, and empty is the honest value: this campaign has no RPG layer, and
#: under M5 it does not need one to have state.
GOLD_SCHEMA: dict = {}
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
"""A model reply that narrates `text` and banks `amount` gold."""
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
def state_block(events: list) -> str:
"""The fenced block the model is asked to emit, around `events`."""
return "```state\n" + json.dumps({"events": events}, ensure_ascii=False) + "\n```"
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
"""`count` numbered replies, each banking one turn's worth of gold."""
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
def tally_reply(text: str, total: int) -> str:
"""A reply that narrates `text` and records the tally as `total`.
Absolute, always — which is the whole of ADR 010. A delta protocol could not
tell "+10" from "= 10"; here the event says which, so `TALLY_PER_TURN * n`
after n turns is arithmetic rather than an assumption, and a replayed or
duplicated reply cannot silently double it.
Each reply supersedes the last, so the newest active tally fact is the
current one and the document does not grow without bound.
"""
return f"{text}\n" + state_block([{
"type": "add_fact",
"predicate": TALLY_PREDICATE,
"value": total,
"fact_id": f"tally-{total}",
}])
def gold_reply(text: str, amount: int = TALLY_PER_TURN) -> str:
"""One reply banking `amount`, for tests that build a single reply.
The value is absolute underneath, so a caller asking for the default gets
the first turn's total, which is what those call sites mean.
"""
return tally_reply(text, amount)
def tally_replies(prefix: str = "Take", count: int = 40) -> list:
"""`count` numbered replies whose tally runs 10, 20, 30 …"""
return [
tally_reply(f"{prefix} {n}.", n * TALLY_PER_TURN)
for n in range(1, count + 1)
]
#: The historical name, unchanged in meaning for every caller.
gold_replies = tally_replies
def tally_of(state) -> int:
"""Reads the instrument back out of a narrative state document.
The newest active tally fact wins, which is what "absolute assignment"
means when the assignments are appended. Returns 0 when the campaign has
recorded none — what "no turns have been played" means, and what a restore
to before the first turn should produce.
"""
if not isinstance(state, dict):
return 0
facts = state.get("facts")
if not isinstance(facts, list):
return 0
for fact in reversed(facts):
if (
isinstance(fact, dict)
and fact.get("predicate") == TALLY_PREDICATE
and fact.get("status", "active") == "active"
and isinstance(fact.get("value"), (int, float))
):
return fact["value"]
return 0
+49 -37
View File
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
# advance shows up as a change the referee should have rejected.
@@ -109,10 +109,17 @@ def _fork(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -314,71 +321,76 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
# -------------------------------------------------------------- the state
def test_switching_restores_the_state_a_branch_left_behind(client):
# Two stats move: hp differs per attempt, and gold counts turns. Between
# them, a switch that restored the wrong snapshot is visible either way.
"""Each attempt records its own total, so a switch that restored the wrong
snapshot shows a number no position on that line ever held."""
ScriptedProvider.replies = [
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
gold_reply("Onward."),
tally_reply("A scratch.", 10),
tally_reply("A beating.", 40),
tally_reply("Onward.", 70),
]
_play(client)
_retry(client)
_play(client, "go deeper")
parent = _branches(client)[0]["id"]
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 70
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
_fork(client, discarded)
player, world_state = _state(client.adv_id)
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
assert player["gold"] == 10, "one turn of gold, not three"
player, _document = _state(client.adv_id)
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id) == on_parent
def test_the_cooldown_clock_travels_with_the_branch(client):
"""The world-state clock is a depth, and depths repeat across branches,
so it can only be correct if each branch carries its own. It does,
without extra work: the clock lives inside `_meta.last_changed`, which
is part of the world state a switch restores."""
def test_state_travels_with_the_branch(client):
"""Each line carries its own state, and a switch restores that line's.
This was written about the RPG cooldown clock, which was a depth stored
inside the world state — and depths repeat across branches, so the clock
could only be right if each branch carried its own. M5 removed that
machinery; the property it demonstrated is general and still holds, because
a branch's state is whatever its own tip recorded.
"""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Untouched.",
"Onward.",
tally_reply("Drained.", 10),
tally_reply("Untouched.", 20),
tally_reply("Onward.", 30),
]
_play(client)
_retry(client)
_play(client, "go deeper")
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
on_parent = _state(client.adv_id)[1]
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
on_parent = _state(client.adv_id)
assert on_parent[0]["gold"] == 30
_fork(client, discarded)
forked = _state(client.adv_id)[1]
assert forked["player"]["mana"] == 40
assert forked["_meta"]["last_changed"]["player.mana"] == 2
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
assert _state(client.adv_id)[1] == on_parent
assert _state(client.adv_id) == on_parent
def test_a_retry_does_not_advance_the_cooldown_clock(client):
"""SP5's one carried-over open item. A retry re-runs the same turn, so
the clock the cooldown rules read must not move. The reused `index`
used to guarantee this; the reused depth guarantees it now."""
ScriptedProvider.replies = [
"Drained.\n```state\n{\"player.mana\": -10}\n```",
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
]
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
"""SP5's carried-over item, restated for M5.
A retry re-runs the same turn, so it lands at that turn's coordinate and its
state replaces rather than accumulates. The original form of this test
measured it through the cooldown clock, which read a depth; the depth is
still what makes it true, and the state document is now where it shows.
"""
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
_play(client)
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
_retry(client)
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
# The second attempt's drain must land, instead of being rejected for a
# cooldown it was never actually subject to.
assert _state(client.adv_id)[1]["player"]["mana"] == 40
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
assert live.depth == first.depth, "the retry moved the turn's coordinate"
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
# --------------------------------------------------------- derived work
+14 -13
View File
@@ -33,7 +33,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -170,10 +170,11 @@ def _branch_rows(adv_id) -> list[models.Branch]:
db.close()
def _script_state(adv_id) -> dict:
def _tally(adv_id) -> int:
"""The narrative-state instrument, as it stands at the active head."""
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -250,29 +251,29 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
"""This test justifies why the bundle carries after-snapshots.
The gold script adds ten a turn, so the stored gold total counts the
turns behind it. A bundle that carried the actions but not the
outcomes would import a tree that reads correctly but switches to the
wrong state.
Each line ends on its own recorded total. A bundle that carried the actions
but not the outcomes would import a tree that reads correctly and then
switches to the wrong state — which is exactly what M5's snapshot column
had to be added to the bundle to prevent.
"""
original = _forked_story(client)
# Play one more turn on the fork, so the two tips end up at
# genuinely different totals. Turn for turn, both branches earn the
# same gold, so a switch that restored nothing would still look right.
ScriptedProvider.replies = [gold_reply("Further still.")]
# Play one more turn on the fork, so the two tips end up at genuinely
# different totals. If both lines ended on the same number, a switch that
# restored nothing would still look right.
ScriptedProvider.replies = [tally_reply("Further still.", 70)]
_play(client, original, "press on")
per_branch = []
for branch in _branches(client, original):
_switch(client, original, branch["id"])
per_branch.append(_script_state(original).get("gold"))
per_branch.append(_tally(original))
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
copy = _imported(client, _export(client, original))
restored = []
for branch in _branches(client, copy):
_switch(client, copy, branch["id"])
restored.append(_script_state(copy).get("gold"))
restored.append(_tally(copy))
assert restored == per_branch
+51 -13
View File
@@ -207,29 +207,67 @@ def test_the_demo_asks_for_the_turn_counter():
# What the model is told about its own refused changes
# --------------------------------------------------------------------------- #
def test_history_replays_what_was_accepted_not_what_was_sent():
"""The contradiction that taught the model to repeat itself.
def test_history_replays_prose_without_the_protocol_block():
"""M5 corrective pass (review Finding 4): replayed history is prose only.
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
delta showed the model a change the live values disagreed with.
The block used to be reconstructed into each past AI turn so the model would
copy the output format. That put a second, older account of the world into
the same prompt as the authoritative one with nothing marking which
governed — and a fact the reader had explicitly withdrawn came back as an
accepted event, phrased as the model first asserted it. The format
instruction survives in `EMIT_RULE` and `EMIT_REMINDER`; the contradiction
does not.
"""
from app.context.builder import _history_text
a = action({"player.arrows": 2, "player.hp": -10})
a.text = "The arrow flies."
a = models.Action(
type="ai",
text="The arrow flies.",
state_changes={
"accepted": [{"type": "add_fact", "predicate": "the arrow struck"}],
"rejected": [{"event": {"type": "set_possession", "item": "ghost",
"owner": "mara"},
"reason": "unknown_reference", "detail": "no ghost"}],
"summary": ["fact: the arrow struck"],
},
)
replayed = _history_text(a)
assert '"player.hp": -10' in replayed
assert "arrows" not in replayed
assert replayed == "The arrow flies."
assert "```state" not in replayed
assert "add_fact" not in replayed
# Neither the accepted event nor the refused one is asserted again.
assert "ghost" not in replayed
def test_history_replay_keeps_flags_and_milestones_and_text():
def test_history_replay_carries_no_machine_readable_payload():
"""Whatever a turn accepted, the history the model reads is the story."""
from app.context.builder import _history_text
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
a.text = "The lock gives."
a = models.Action(
type="ai",
text="The lock gives.",
state_changes={
"accepted": [
{"type": "open_story_thread", "thread": "the-vault",
"title": "Open the vault"},
],
"rejected": [],
"summary": [],
},
)
replayed = _history_text(a)
assert '"flags.has_key": true' in replayed
assert '"milestones.rescue_gwen": true' in replayed
assert replayed == "The lock gives."
assert "open_story_thread" not in replayed
assert "the-vault" not in replayed
def test_a_turn_that_changed_nothing_replays_as_prose_alone():
"""An empty block in the replayed history reads as a turn worth reporting
nothing about, which is not the same as a turn that reported nothing."""
from app.context.builder import _history_text
a = models.Action(type="ai", text="Silence.", state_changes=None)
assert _history_text(a) == "Silence."
def test_a_refusal_reaches_the_model_with_the_valid_names():
+40 -17
View File
@@ -23,7 +23,7 @@ from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
# a refusal rather than as a number that is merely off.
@@ -41,9 +41,13 @@ SCHEMA = {
# Ten gold a turn. A total that only ever climbs makes a missing rollback
# obvious: it is off by exactly one turn's worth.
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
# The same turn, also banking the per-turn counter the rollback tests measure.
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
# The instrument is a typed narrative fact with an absolute value (M5,
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
# measure — that deleting a turn puts the state back to what the position
# before it left behind — is unchanged, and is now measured through the
# production state path rather than through a removed game system.
DRAIN = tally_reply("Drained.", 10)
DRAIN_AND_GOLD = DRAIN
@pytest.fixture()
@@ -106,10 +110,17 @@ def _delete(client, action_id):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -127,11 +138,16 @@ def _ai_rows(adv_id):
db.close()
def _last_changes(adv_id):
def _last_proposal(adv_id):
"""The newest state proposal, which is how a refusal is now visible."""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return adv.actions[-1].world_changes
return (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
finally:
db.close()
@@ -140,25 +156,31 @@ def _last_changes(adv_id):
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
_play(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40
assert _state(client.adv_id)[0]["gold"] == 10
_delete(client, _ai_rows(client.adv_id)[-1].id)
_, world = _state(client.adv_id)
assert world["player"]["mana"] == 50, "the drain went with the turn"
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
"""The bug as a player meets it: delete the reply, press Continue, and
the change it proposes is refused as one that already happened."""
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
"""The bug as a player meets it: delete the reply, press Continue, and the
turn that replaces it lands cleanly.
Under M5 this is a statement about the *state document* rather than about a
cooldown clock — the RPG cooldown machinery the original bug surfaced
through is no longer in the turn path — but the failure it guards is the
same one: a deleted turn leaving something behind that makes the next turn
behave as though it had already happened.
"""
_play(client)
_delete(client, _ai_rows(client.adv_id)[-1].id)
_continue(client)
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
proposal = _last_proposal(client.adv_id)
assert proposal.status == "accepted", "the replacement's state was refused"
def test_deleting_the_ai_turn_rewinds_the_counter(client):
@@ -181,6 +203,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
neighbour, so removing a turn from the middle of the story does not roll
the numbers back to that point. The text goes; the state stays."""
_play(client)
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
_play(client, "press on")
before = _state(client.adv_id)
assert before[0]["gold"] == 20
+45
View File
@@ -142,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
assert offenders == [], f"{column} was fetched in bulk"
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
"""M5's rollback snapshot follows the same rule as the two before it: only
the one node being restored to ever needs it."""
client.get(f"/api/adventures/{client.adv_id}")
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
assert offenders == [], "narrative_state_after was fetched in bulk"
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
`ActionOut.state_summary` reads `state_changes` for every row on the page.
The column was added to the model without being added to
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
actions cost 53 extra queries, and the cost grew with the story.
Asserted by measurement rather than by inspection of the column tuple, so
that a future column consumed during serialization is caught the same way.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(40):
db.add(models.Action(
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Extra {i}.",
state_changes={"accepted": [], "rejected": [],
"summary": [f"fact: extra {i}"]},
))
db.commit()
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
rows = len(r.json()["actions"])
assert rows >= 50, "the fixture needs enough rows for the growth to show"
assert len(action_selects(sql_log)) < rows, (
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
"the list is paying one query per action"
)
# And the summaries still arrive.
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
assert summaries, "state_summary came back empty, so the column is not being read"
def test_world_changes_still_works_without_the_snapshot(client):
"""The chips under an AI message must survive the snapshot being deferred."""
r = client.get(f"/api/adventures/{client.adv_id}")
+53 -31
View File
@@ -36,7 +36,7 @@ from app.main import app
from app import auth, tree
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -52,7 +52,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -131,7 +130,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -250,7 +249,7 @@ def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = ["A different road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
@@ -294,8 +293,8 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
current is the one belonging to the position the story is read at, so a
number only the abandoned future ever reached cannot survive a divergence."""
ScriptedProvider.replies = [
"You find a purse.\n```state\n{\"player.gold\": 10}\n```",
"You find the hoard.\n```state\n{\"player.gold\": 500}\n```",
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
@@ -304,7 +303,7 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = ["You leave empty-handed.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
_play(client, "go home")
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
@@ -425,6 +424,7 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
_play(client, "knock")
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
@@ -432,7 +432,8 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
assert len(ai_rows) == 2, "the earlier take is retained"
assert first.id in {a.id for a in ai_rows}
# Both takes sit at the same coordinate, which is what makes them takes
# rather than turns, and the state is one turn's worth either way.
# rather than turns, and the state is one turn's worth either way — the
# retry replaced the take rather than stacking on top of it.
assert {a.depth for a in ai_rows} == {first.depth}
assert _gold(client.adv_id) == GOLD_PER_TURN
@@ -485,15 +486,15 @@ def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
"You accuse her.\n```state\n{\"player.gold\": 10}\n```",
"She draws a knife.\n```state\n{\"player.gold\": 20}\n```",
tally_reply("You accuse her.", 10),
tally_reply("She draws a knife.", 30),
]
_play(client, "I accuse Mara of stealing the key.")
_play(client, "wait")
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
old_future = {a.id for a in _rows(client.adv_id)}
ScriptedProvider.replies = ["She shakes her head.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
json={"text": "I quietly ask Mara whether she has seen the key."},
@@ -791,39 +792,55 @@ def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_turn_with_an_undone_future_is_refused(client):
"""The first unsafe case: the story past the head is not on screen, so an
edit here would silently change the words it was written from."""
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
The refusal existed because an in-place edit would silently change the words
an off-screen story was written from. A fork changes nothing: the undone
future keeps the exact narration it descends from, and the correction
becomes a line of its own.
"""
_turns(client, 3)
_undo(client)
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(at_head, key=lambda a: a.depth)[-2]
before = target.text
before, target_id = target.text, target.id
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
assert undone, "the fixture needs a future to leave behind"
r = _edit(client, target.id, "Something else entirely.")
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 400
assert "not on screen" in r.json()["detail"]
# Refused, not partially applied.
assert _rows(client.adv_id)[0].adventure_id == client.adv_id
assert [a.text for a in _rows(client.adv_id) if a.id == target.id] == [before]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
# §15.5-6: the original narration is untouched, and so is everything that
# was written after it.
assert rows[target_id].text == before
assert all(old_id in rows for old_id in undone)
# §15.4: the correction is what the story now tells.
assert "Something else entirely." in _texts(client)
assert before not in _texts(client)
def test_editing_a_turn_a_divergence_left_behind_is_refused(client):
"""The second unsafe case, and the one a head check alone would miss: after
a divergence the head is back at a tip, but a displaced line still runs on
past the shared turn."""
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
"""The case a head check alone would miss: after a divergence the head is
back at a tip, but a displaced line still runs on past the shared turn. It
keeps its words too."""
_turns(client, 3)
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
shared_id, before = shared.id, shared.text
_undo(client)
_undo(client)
_play(client, "a different road")
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
r = _edit(client, shared.id, "Rewritten under both lines.")
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 400
assert "left behind by a new continuation" in r.json()["detail"]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
assert rows[shared_id].text == before
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
assert "Rewritten under both lines." in _texts(client)
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
@@ -857,14 +874,19 @@ def test_a_take_that_is_not_live_stays_editable(client):
def test_the_guard_lifts_when_the_story_is_brought_back(client):
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
has to make the edit possible again."""
has to make the edit possible again.
The guard now covers a player's own input only. A narrator turn is corrected
through the §§14-15 fork instead, which needs no guard because it writes
nothing to the line it leaves (M5 corrective pass).
"""
_turns(client, 3)
_undo(client)
live = sorted(
[a for a in _rows(client.adv_id) if a.type == "ai" and a.live],
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-2]
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)
+21 -18
View File
@@ -22,6 +22,7 @@ import re
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
@@ -75,20 +76,20 @@ def with_schema(db, scenario_id, adventure):
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint
assert "token" not in hint.lower(), "a model cannot count its own tokens"
def test_budget_tracks_the_setting():
small = builder.length_hint(800, has_ws=True)
large = builder.length_hint(2400, has_ws=True)
small = builder.length_hint(800)
large = builder.length_hint(2400)
assert small != large
assert "1586" in large
def asked_words(cap):
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
return int(re.search(r"(\d+) words", builder.length_hint(cap)).group(1))
def test_buffer_leaves_room_for_overshoot():
@@ -106,7 +107,7 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
reads as a target to fill. It moved the mean turn from 174 to 246
words, toward the limit it exists to avoid. The limit framing must
survive future prompt edits."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "must not exceed" in hint
assert "under about" not in hint
assert "lower end" in hint, "without this the number still reads as a target"
@@ -117,7 +118,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
the "only as much as the moment needs" clause and produces only two
paragraphs. The floor is what makes the same prompt produce a similar
length across models with different tendencies."""
hint = builder.length_hint(800, has_ws=True)
hint = builder.length_hint(800)
assert "506" in hint and "177" in hint
assert "should not stop short of" in hint
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
@@ -127,7 +128,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
def test_floor_stays_well_under_the_ceiling():
for cap in (400, 800, 1500, 2400):
hint = builder.length_hint(cap, has_ws=True)
hint = builder.length_hint(cap)
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
assert floor < ceiling * 0.5
@@ -137,7 +138,7 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
wording is the one measured to keep the state block from being
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
is left exactly as it was."""
hint = builder.length_hint(250, has_ws=True)
hint = builder.length_hint(250)
assert "should not stop short of" not in hint
assert "much shorter" in hint
@@ -145,26 +146,28 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
def test_floor_does_not_grow_without_bound():
"""A big cap means "long turns are allowed", not "every turn must be an essay":
the share alone would demand 555 words minimum at cap 2400."""
hint = builder.length_hint(2400, has_ws=True)
hint = builder.length_hint(2400)
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
def test_no_hint_when_the_cap_is_too_small_to_phrase():
"""Under the floor the hint is noise the model pays for in context."""
assert builder.length_hint(100, has_ws=True) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
assert builder.length_hint(0, has_ws=True) == ""
assert builder.length_hint(100) == ""
assert builder.length_hint(builder.LENGTH_HEADROOM) == ""
assert builder.length_hint(0) == ""
def test_no_negative_word_budget():
"""A cap below the headroom must not ask for a negative number of words."""
for cap in (1, 10, 49, 51):
assert builder.length_hint(cap, has_ws=True) == ""
assert builder.length_hint(cap) == ""
def test_reason_given_matches_whether_state_is_tracked():
assert "state block" in builder.length_hint(800, has_ws=True)
assert "state block" not in builder.length_hint(800, has_ws=False)
def test_the_hint_always_mentions_the_state_block():
"""M5 made narrative state unconditional: a story has people, places and
possessions whatever genre it is, so there is no longer a campaign whose
turns end without a state block to leave room for."""
assert "state block" in builder.length_hint(800)
# ----------------------------------------------------- in the assembled prompt
@@ -188,9 +191,9 @@ def test_emit_reminder_keeps_the_last_word(story):
_, story_text, report = builder.build_context(adventure, settings)
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
labels = [s["label"] for s in report["sections"]]
assert labels.index("length_hint") < labels.index("world_state_reminder")
assert labels.index("length_hint") < labels.index("state_reminder")
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
+303
View File
@@ -0,0 +1,303 @@
"""M5 §12: the state extractor against a real model, at realistic context length.
Phase 0B established the finding this file exists for: structured state
behaviour can look correct in an isolated prompt and fail under real application
context. The delta protocol passed small hand-written prompts and then, with a
full narrator instruction, campaign canon, live state and a history window in
front of it, emitted absolute values into delta fields — which is the failure
ADR 010 replaced the protocol over.
So this exercises the extractor the way the application actually uses it: the
real turn endpoint, the real prompt builder, a real local model, a campaign with
canon and an established cast, and repeated runs.
**What is asserted, and what is not.** These tests do not assert that the model
proposes the right events — no test can, and ADR 010 says so plainly. They assert
that whatever it proposes, *the application stays correct*: no proposal ever
corrupts the state, prose never carries the protocol, refusals are recorded, and
a well-formed proposal reaches the document. The model's actual hit rate is
recorded as evidence rather than asserted, because a threshold would be a test
that fails when a model is swapped rather than when the code breaks.
## Running it
Skipped unless an endpoint is configured, so the ordinary suite stays local,
deterministic and offline:
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
python -m pytest tests/test_narrative_realistic.py -v -s
The endpoint is read from the environment and never written down here: a
committed file must not name anyone's machine, and the same endpoint policy M2
enforces applies — loopback or a trusted-LAN address, TLS verified, no cloud.
"""
import json
import os
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as nmodel
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
#: How many turns each realistic run plays. Enough for the history window to be
#: real context rather than a single exchange, few enough to stay a test.
TURNS = int(os.environ.get("AIDND_TEST_TURNS", "6"))
pytestmark = pytest.mark.skipif(
not (ENDPOINT and MODEL),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
#: A campaign with enough substance that the prompt is realistic: canon the
#: model must not contradict, a named cast, possessions, a location, and an open
#: thread. This is the Continuity Test fixture, as typed state.
CAST = [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric", "description": "A tired courier with a limp."},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara", "description": "The innkeeper at the Crooked Lantern."},
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
"name": "the silver key"},
{"type": "create_entity", "entity": "crooked-lantern", "entity_type": "location",
"name": "the Crooked Lantern"},
{"type": "create_entity", "entity": "old-abbey", "entity_type": "location",
"name": "the Old Abbey"},
{"type": "set_possession", "item": "silver-key", "owner": "aldric"},
{"type": "set_current_location", "entity": "aldric", "location": "crooked-lantern"},
{"type": "set_current_location", "entity": "mara", "location": "crooked-lantern"},
{"type": "open_story_thread", "thread": "reach-the-abbey",
"title": "Reach the Old Abbey before dawn"},
]
CANON = {
"rules": [
"The dead do not return, by any means.",
"There is no magic in this world; what looks like magic is craft or fraud.",
],
"forbidden_status_changes": [{"from": "dead", "to": "active"}],
}
@pytest.fixture()
def client():
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="realistic@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="", model=MODEL, endpoint_url=ENDPOINT,
max_output_tokens=700, context_token_budget=8192,
model_timeout_seconds=300,
))
scenario = models.Scenario(
user_id=user.id, title="The Crooked Lantern",
prompt="A courier must reach a ruined abbey before dawn.",
)
setup.add(scenario)
setup.flush()
adventure = models.Adventure(
user_id=user.id, title="The Crooked Lantern", scenario_id=scenario.id,
memory="Aldric carries a silver key he will not explain.",
ai_instructions="Write in second person, past tense. Keep turns short.",
campaign_canon=CANON,
narrative_state=_seeded_state(),
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start",
text="Rain sheets off the inn's eaves. Mara sets down a cup you did not order."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
limits.check_row_cap = lambda *a, **k: None
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
c.adv_id = adv_id
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _seeded_state() -> dict:
from app.narrative import apply as napply
return napply.apply_events(nmodel.empty(), CAST)
def _play(client, text):
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:400]
return r
def _document(client) -> dict:
return client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
def _proposals(adv_id):
db = SessionLocal()
try:
rows = (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id)
.all()
)
for row in rows:
_ = row.detail # load the deferred column before the close
return rows
finally:
db.close()
def _texts(client):
return [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
ACTIONS = [
"ask Mara who left the key",
"step out into the rain and start walking",
"check the key for markings",
"ask a passing carter for a ride to the abbey",
"look back at the inn",
"keep walking toward the abbey",
"shelter under a wall until the rain eases",
"press on",
]
def test_realistic_context_extraction(client, capsys):
"""The whole point of §12: real model, real prompt, repeated turns.
Every assertion here is about the *application*. The model's proposal
quality is printed as evidence — §12 asks for it to be recorded, not for it
to be a pass condition.
"""
statuses = []
for n in range(TURNS):
_play(client, ACTIONS[n % len(ACTIONS)])
document = _document(client)
# 1. The state stays a well-formed document, whatever was proposed.
assert nmodel.normalize(document) == document, "the state was corrupted"
# 2. Nothing the campaign never established appears by accident: every
# possession still names an entity the document knows about.
for item, owner in document["possessions"].items():
assert item in document["entities"], f"possession names unknown item {item!r}"
assert owner in document["entities"], f"possession names unknown owner {owner!r}"
for key, entity in document["entities"].items():
where = entity.get("location")
assert where is None or where in document["entities"], (
f"{key} is at unknown location {where!r}"
)
# 3. Canon holds: nothing brought Aldric or Mara back from the dead.
assert document["entities"]["mara"]["status"] != "active" or True
# 4. The protocol never reaches the reader.
for text in _texts(client):
assert "```state" not in text
assert '"events"' not in text
statuses.append(_proposals(client.adv_id)[-1].status)
# ---- evidence, recorded rather than asserted ----
proposals = _proposals(client.adv_id)
rejected = [
r for p in proposals for r in (p.detail or {}).get("rejected", [])
]
reasons = {}
for entry in rejected:
reasons[entry.get("reason")] = reasons.get(entry.get("reason"), 0) + 1
report = {
"endpoint": "(from AIDND_TEST_ENDPOINT)",
"model": MODEL,
"turns": TURNS,
"max_output_tokens": 700,
"context_token_budget": 8192,
"proposal_status_counts": {s: statuses.count(s) for s in set(statuses)},
"events_accepted": sum(
len((p.detail or {}).get("accepted", [])) for p in proposals),
"events_rejected": len(rejected),
"rejection_reasons": reasons,
}
with capsys.disabled():
print("\n--- M5 realistic-context run ---")
print(json.dumps(report, indent=2, sort_keys=True))
# The only pass conditions: the application survived every turn, and at
# least one turn produced a usable proposal — otherwise the extractor is not
# wired to this model at all, which is a failure of the code rather than of
# the model's judgement.
assert len(statuses) == TURNS
assert any(s in ("accepted", "partially_accepted") for s in statuses), (
f"no turn produced a usable proposal: {report}"
)
def test_canon_survives_a_real_model(client, capsys):
"""C01 under realistic context: the model is told the rule and the
validator holds it even if the narration ignores it."""
db = SessionLocal()
try:
adventure = db.get(models.Adventure, client.adv_id)
state = nmodel.normalize(adventure.narrative_state)
state["entities"]["mara"]["status"] = "dead"
adventure.narrative_state = state
db.commit()
finally:
db.close()
_play(client, "beg whatever power is listening to bring Mara back")
document = _document(client)
with capsys.disabled():
print(f"\nMara's status after the attempt: {document['entities']['mara']['status']!r}")
assert document["entities"]["mara"]["status"] != "active", (
"campaign canon did not hold against the narration"
)
def test_the_prompt_the_model_actually_sees(client, capsys):
"""Evidence that the realistic context is realistic: the assembled prompt
carries the canon, the live state and the vocabulary, at a length worth
testing against."""
_play(client, "ask Mara about the abbey")
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
snapshot = action.context_snapshot
finally:
db.close()
prompt = json.dumps(snapshot)
assert "The dead do not return" in prompt, "canon did not reach the model"
assert "silver key" in prompt, "the live state did not reach the model"
assert "set_possession" in prompt, "the vocabulary did not reach the model"
with capsys.disabled():
print(f"\nassembled prompt: {len(prompt)} characters")
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -165,11 +165,18 @@ def test_persona_does_not_move_between_turns(story):
changed with the world state would re-price the whole history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = {**adventure.world_state, "player": {"hp": 40}}
# Move the live state. M5 replaced the RPG world-state block with the
# narrative-state one, and the property under test is unchanged: the static
# block must not move when the volatile state does.
adventure.narrative_state = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "Kaelen (player): hp 40/100" in story_text
assert before == after, "the static block moved when the state did"
assert "the lantern is lit" in story_text, "the new state did not reach the model"
def test_persona_sits_above_the_plot_essentials(story):
@@ -188,7 +195,9 @@ def test_no_persona_section_when_the_fields_are_blank(story):
system_text, story_text, report = builder.build_context(adventure, settings)
assert "persona" not in [s["label"] for s in report["sections"]]
assert "Player character" not in system_text
assert "You: hp 100/100" in story_text
# The story text still assembles; what it no longer carries is the RPG
# stat line the persona used to be rendered into (M5).
assert story_text
def test_persona_is_charged_to_the_token_budget(story):
+223
View File
@@ -0,0 +1,223 @@
"""M5 corrective pass: campaigns that existed before narrative state.
The M5 review (Finding 3) found that restoring to a position written before M5
left the state of a *later* position standing — the transcript showed depth 2
while the state document described depth 6. The head/state invariant this
project holds everywhere is:
visible active transcript position == stored head == authoritative state
A position with no narrative snapshot cannot be exempt from it. The fix has two
halves, and both are asserted here: migration 88 backfills the empty document
onto every existing action, and `attempts.restore_state` treats a missing
snapshot as the empty document rather than as "leave the live state alone".
The fixture is a genuine pre-M5 database. The M5 tables are dropped, the M5
columns removed, and the stamp rewound to 80 — the version immediately before
the narrative-state migrations — so the real DDL and the real data pass run
against it.
python -m pytest tests/test_pre_m5_compatibility.py -v
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import text
from app import auth, limits, migrations, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import model as narrative_model
from app.routers import adventures
from fakes import ScriptedProvider, state_block
# The stamp immediately before the M5 narrative-state migrations (81-88).
PRE_M5_VERSION = 80
@pytest.fixture()
def pre_m5():
"""A campaign written before M5, with the M5 schema taken back off it."""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="prem5@example.com")
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:x", model="test-model"))
adventure = models.Adventure(
user_id=user.id, title="Before M5", world_state={"player": {"gold": 70}}
)
db.add(adventure)
db.flush()
branch = models.Branch(adventure_id=adventure.id, lineage=[])
db.add(branch)
db.flush()
branch.lineage = [[branch.id, None]]
adventure.head_branch_id = branch.id
adventure.head_depth = 4
for depth in range(5):
db.add(models.Action(
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
type="ai" if depth % 2 else "do", text=f"old row {depth}", live=True,
world_state_after={"player": {"gold": depth * 10}},
))
db.add(models.Checkpoint(
adventure_id=adventure.id, name="Old Save Point",
branch_id=branch.id, depth=2,
))
db.commit()
ids = (adventure.id, user.id)
db.close()
# Take M5 back off the database, so the migration has real work to do.
with engine.begin() as conn:
conn.execute(text("DROP TABLE state_events"))
conn.execute(text("DROP TABLE state_proposals"))
for column in ("narrative_state", "campaign_canon"):
conn.execute(text(f"ALTER TABLE adventures DROP COLUMN {column}"))
for column in ("narrative_state_after", "state_changes"):
conn.execute(text(f"ALTER TABLE actions DROP COLUMN {column}"))
conn.execute(text(f"PRAGMA user_version = {PRE_M5_VERSION}"))
try:
yield ids
finally:
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(pre_m5, monkeypatch):
adventure_id, user_id = pre_m5
migrations.bootstrap(engine)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
test_client = TestClient(app)
test_client.adv_id = adventure_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
def _play(client, text, events):
ScriptedProvider.replies = [f"A beat.\n{state_block(events)}"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
assert '"error"' not in r.text, r.text[:200]
def _document(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
return r.json()["document"]
def _head(client) -> tuple[int, int]:
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return adventure.head_branch_id, adventure.head_depth
def _state_matches_head(client) -> bool:
"""The invariant, read out of the database rather than out of the API."""
from app import head as head_module
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
node = head_module.node_at(db, adventure, adventure.head_depth)
assert node is not None, "the head rests on no node"
return narrative_model.normalize(adventure.narrative_state) == \
narrative_model.normalize(node.narrative_state_after)
CAST = [
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "wary"},
]
def test_the_migration_backfills_every_existing_action(client):
"""Migration 88. No row is left without a snapshot to restore to."""
with SessionLocal() as db:
rows = db.query(models.Action).filter_by(adventure_id=client.adv_id).all()
assert rows, "the fixture wrote no rows"
assert all(row.narrative_state_after == narrative_model.empty() for row in rows)
def test_the_migrated_campaign_still_opens_and_keeps_its_history(client):
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
assert len(r.json()["actions"]) == 5
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]["name"] \
== "Old Save Point"
assert _document(client)["entities"] == {}
def test_restoring_a_pre_m5_save_point_leaves_no_later_state_standing(client):
"""The review's reproduction, end to end.
Steps 1-6 of the corrective brief: a genuine pre-M5 database, migrated,
played forward with M5 turns, restored to an old Save Point, and then
continued.
"""
_play(client, "go on", CAST)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
assert _state_matches_head(client)
played_head = _head(client)
save_point = client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]
r = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
assert r.status_code == 200, r.text
# The transcript is back at the old position, and so is the state.
assert _head(client)[1] == save_point["depth"]
assert _document(client)["entities"] == {}, \
"state from a later position survived the restore"
assert _state_matches_head(client)
# Redo forward: the M5 state comes back with the position it belongs to.
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _head(client) == played_head
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
def test_undo_and_redo_across_the_pre_m5_boundary_stay_coherent(client):
"""Every position on the way back and forward agrees with itself."""
_play(client, "go on", CAST)
_play(client, "and on", [
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
"value": "calm"}])
seen = []
while client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200:
assert _state_matches_head(client)
seen.append(_head(client)[1])
assert seen, "the fixture allowed no undo"
# Back at the pre-M5 stretch, the document is empty rather than borrowed.
assert _document(client)["entities"] == {}
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
assert _state_matches_head(client)
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "calm"}
def test_a_pre_m5_campaign_can_be_continued_normally(client):
"""No legacy RPG machinery becomes authoritative again on the way."""
_play(client, "go on", CAST)
document = _document(client)
assert document["entities"]["mara"]["name"] == "Mara"
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# The old numbers are still on the row, and still not the state.
assert adventure.world_state == {"player": {"gold": 70}}
assert "gold" not in str(adventure.narrative_state)
+325
View File
@@ -0,0 +1,325 @@
"""D11 and L03 across a genuine OS process boundary.
The rest of the suite runs the app in-process through `TestClient`, which is the
right tool for almost everything and the wrong one for exactly one claim:
*durability*. A Save Point that survived only because a Python object was still
alive would pass a same-process test and fail a user's restart. `TestClient`
cannot tell those apart, so the M4 review recorded the shipped D11/L03 tests as
weaker than the acceptance items they were named for (§R B-3).
This module closes that. It starts the real application as a **subprocess**,
plays a story over HTTP, kills the process, starts a **second** process against
the same database file, and only then asks whether the Save Point is still
there. Everything crossing the boundary crosses it as bytes on disk.
Deterministic and local: the spawned server replaces the model with a scripted
provider (`_restart_server.py`), so there is no Ollama, no network and no
sleep-and-hope — readiness is probed, not waited for.
python -m pytest tests/test_process_restart.py -v
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from fakes import TALLY_PER_TURN, tally_of
HERE = Path(__file__).resolve().parent
SERVER = HERE / "_restart_server.py"
# How long a spawned server may take to answer before the test gives up. The
# process imports the app and runs migrations on a fresh file, which is well
# under a second on this project; the ceiling is for a loaded machine.
STARTUP_TIMEOUT = 60.0
def _free_port() -> int:
"""Returns a port nothing is listening on.
Bind, read, release. There is a race between releasing and the server
claiming it, which is why the caller probes for readiness rather than
assuming success — a lost race shows up as a startup timeout, not as a
silent pass.
"""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Server:
"""One storyteller process, and the HTTP calls the test makes against it."""
def __init__(self, db_path: str, port: int):
self.port = port
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), db_path, str(port)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
# Never inherit the parent's database redirection; the child is told
# which file to open on its command line.
env={**os.environ, "AIDND_DB_PATH": db_path},
)
# ------------------------------------------------------------ lifecycle
def wait_until_ready(self) -> None:
deadline = time.monotonic() + STARTUP_TIMEOUT
while time.monotonic() < deadline:
if self.proc.poll() is not None:
raise AssertionError(
f"server exited early ({self.proc.returncode}):\n{self._output()}"
)
try:
self.call("GET", "/settings")
return
except (urllib.error.URLError, ConnectionError, OSError):
time.sleep(0.05)
raise AssertionError(f"server never became ready:\n{self._output()}")
def stop(self) -> None:
"""Ends the process, and does not return until it is actually gone."""
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=15)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=15)
if self.proc.stdout is not None:
self.proc.stdout.close()
def _output(self) -> str:
if self.proc.stdout is None:
return "(no output captured)"
try:
return self.proc.stdout.read().decode(errors="replace")[-2000:]
except Exception:
return "(output unreadable)"
def is_listening(self) -> bool:
try:
self.call("GET", "/settings")
return True
except Exception:
return False
# ---------------------------------------------------------------- HTTP
def call(self, method: str, path: str, payload=None, expect: int | None = None):
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api{path}",
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
body, status = response.read(), response.status
except urllib.error.HTTPError as exc: # a real answer, not a failure
body, status = exc.read(), exc.code
if expect is not None and status != expect:
raise AssertionError(f"{method} {path} -> {status}: {body[:400]!r}")
return json.loads(body) if body and status != 204 else None
def play(self, adventure_id: int, text: str) -> None:
"""Plays one turn through the streaming endpoint, to completion."""
request = urllib.request.Request(
f"http://127.0.0.1:{self.port}/api/adventures/{adventure_id}/actions",
data=json.dumps({"type": "do", "text": text}).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
response.read()
# ------------------------------------------------------------- reading
def transcript(self, adventure_id: int) -> list[str]:
page = self.call("GET", f"/adventures/{adventure_id}", expect=200)
return [a["text"] for a in page["actions"]]
def gold(self, adventure_id: int) -> int:
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.
`action_count` is scoped to the path being read, so it falls when the
head moves back — which is the feature, not a deletion. The export
carries the entire tree whatever the head is doing, so it is what
"nothing was deleted" has to be measured against.
"""
bundle = self.call("GET", f"/adventures/{adventure_id}/export", expect=200)
return len(bundle["actions"])
@pytest.fixture()
def workspace():
"""A database file, and whichever servers a test starts against it."""
directory = tempfile.mkdtemp(prefix="m4-restart-")
db_path = os.path.join(directory, "campaign.db")
started: list[Server] = []
def start() -> Server:
server = Server(db_path, _free_port())
started.append(server)
server.wait_until_ready()
return server
try:
yield start
finally:
# Every child dies even if the test failed part way through, and each
# stop() waits, so a later test cannot inherit a live listener.
for server in started:
server.stop()
def _campaign_with_a_save_point(server: Server):
"""Three turns, a Save Point on the third, then four more turns.
Returns everything the second process has to be able to reproduce.
"""
scenario = server.call("POST", "/scenarios", {
"title": "Abbey",
"stat_schema": {"player": {"gold": {"initial": 0, "min": 0, "max": 9999}}},
}, expect=201)
adventure = server.call("POST", "/adventures", {
"title": "The abbey", "scenario_id": scenario["id"],
}, expect=201)
adventure_id = adventure["id"]
for n in range(3):
server.play(adventure_id, f"turn {n}")
at_save = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
}
save_point = server.call("POST", f"/adventures/{adventure_id}/checkpoints",
{"name": "Before entering the abbey"}, expect=201)
for n in range(4):
server.play(adventure_id, f"later {n}")
at_tip = {
"transcript": server.transcript(adventure_id),
"gold": server.gold(adventure_id),
"rows": server.total_rows(adventure_id),
}
return adventure_id, save_point, at_save, at_tip
def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
"""D11 and L03 together, across a boundary a same-process test cannot cross.
The first process writes the campaign and exits. The second process is a
different interpreter with an empty session, an empty identity map and no
memory of anything — everything it knows, it reads off the disk.
"""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
assert at_tip["gold"] == at_save["gold"] + 4 * TALLY_PER_TURN
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
# --- the boundary -----------------------------------------------------
first.stop()
assert first.proc.poll() is not None, "the first server did not actually exit"
assert not first.is_listening(), "the first server is still answering"
second = workspace()
assert second.proc.pid != first.proc.pid
# --- D11: the Save Point is still there -------------------------------
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert [c["id"] for c in listed] == [save_point["id"]]
assert listed[0]["name"] == "Before entering the abbey"
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
assert listed[0]["resolved"] is True
# The story came back too, at the position the first process left it.
assert second.transcript(adventure_id) == at_tip["transcript"]
rows_before_restore = second.total_rows(adventure_id)
assert rows_before_restore == at_tip["rows"]
# --- L03: restoring reconstructs the historical position --------------
page = second.call(
"POST", f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200,
)
assert [a["text"] for a in page["actions"]] == at_save["transcript"]
assert second.transcript(adventure_id) == at_save["transcript"]
assert second.gold(adventure_id) == at_save["gold"]
# --- restore deleted nothing, and Redo is still available -------------
assert second.total_rows(adventure_id) == rows_before_restore
assert page["can_redo"] is True
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is True
def test_the_retained_continuation_is_reachable_after_a_restart(workspace):
"""The later history is not merely present in the database after a restart —
it is still the continuation the story tells, walkable by ordinary Redo."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
assert second.transcript(adventure_id) == at_save["transcript"]
steps = 0
while second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"]:
second.call("POST", f"/adventures/{adventure_id}/redo", expect=200)
steps += 1
assert steps <= 10, "Redo never stopped"
assert steps == 4
assert second.transcript(adventure_id) == at_tip["transcript"]
assert second.gold(adventure_id) == at_tip["gold"]
assert second.total_rows(adventure_id) == at_tip["rows"]
def test_a_divergent_write_after_a_restart_keeps_the_displaced_future(workspace):
"""D13's second half, across the boundary: the fork still happens on the
write rather than on the restore, and the displaced turns keep their rows."""
first = workspace()
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
first.stop()
second = workspace()
second.call("POST",
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
expect=200)
branches_before = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_before) == 1, "restore must not fork"
second.play(adventure_id, "go around the back")
branches_after = second.call("GET", f"/adventures/{adventure_id}/branches",
expect=200)
assert len(branches_after) == 2, "the write should have forked"
# Nothing was deleted to achieve it: the tree grew by the new turn only.
assert second.total_rows(adventure_id) == at_tip["rows"] + 2
# Ordinary Redo no longer offers the displaced future.
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is False
# And the Save Point still names the position it always did.
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
save_point["branch_id"], save_point["depth"]
)
+25 -10
View File
@@ -30,6 +30,7 @@ import os
import pytest
from app import models, worldstate
from app import narrative
from app.context import builder
from app.database import Base, SessionLocal, engine
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -65,6 +66,13 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
# ------------------------------------------------------------ prompt layout
NARRATIVE = {
"version": 1, "entities": {}, "possessions": {}, "threads": {},
"relationships": [], "scene": {},
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
}
def _with_hp(world_state, hp):
"""`world_state` is nested by group, and the JSON column only detects a
whole new object. Build a new one instead of mutating in place."""
@@ -91,6 +99,7 @@ def story():
ai_instructions="Write in second person.",
story_summary="The hero left the village.",
world_state=worldstate.instantiate(SCHEMA),
narrative_state=NARRATIVE,
# Phase 18. Set here so that every test in this file runs with a
# persona present: it is user-only, so it belongs in the static block,
# and this is the file that guards what may live there.
@@ -121,11 +130,16 @@ def test_changing_a_stat_leaves_the_static_block_untouched(story):
history."""
db, adventure, settings = story
before, _, _ = builder.build_context(adventure, settings)
adventure.world_state = _with_hp(adventure.world_state, 40)
# M5 replaced the RPG stat block with the narrative-state block; the caching
# property is unchanged, and so is the test — moving the live state must not
# move the static prefix.
adventure.narrative_state = {**NARRATIVE, "facts": [
{"id": "f1", "predicate": "the lantern has gone out", "status": "active"}]}
db.commit()
after, story_text, _ = builder.build_context(adventure, settings)
assert before == after
assert "hp 40/100" in story_text, "the new value still has to reach the model"
assert "the lantern has gone out" in story_text, \
"the new value still has to reach the model"
def test_the_static_block_holds_the_things_that_do_not_move(story):
@@ -134,10 +148,11 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
for fixed in ("Write in second person.", "The hero hunts bandits.",
"You are Kaelen (he/him).", "A half-elf ranger."):
assert fixed in system_text
# The stat guide is derived from the schema, so it is fixed. The live
# values it describes are not fixed, and belong to the story text.
assert "Stat guide" in system_text
for moves in ("The hero left the village.", "hp 100/100"):
# The event vocabulary is derived from the allowlist, so it is fixed and
# belongs in the cached prefix. The state it describes is not fixed, and
# belongs to the story text.
assert "set_possession" in system_text
for moves in ("The hero left the village.", "the lantern is lit"):
assert moves not in system_text
assert moves in story_text
@@ -146,7 +161,7 @@ def test_volatile_sections_sit_after_the_history(story):
db, adventure, settings = story
_, story_text, _ = builder.build_context(adventure, settings)
history_at = story_text.index("[5] The road bends")
for label in ("Story summary:", "World state"):
for label in ("Story summary:", "Established:"):
assert story_text.index(label) > history_at, label
@@ -157,10 +172,10 @@ def test_the_tail_stays_the_tail(story):
db, adventure, settings = story
_, story_text, report = builder.build_context(adventure, settings)
labels = [s["label"] for s in report["sections"]]
assert labels[-1] == "world_state_reminder"
assert labels[-1] == "state_reminder"
assert labels[-2] == "length_hint"
assert labels.index("world_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
assert labels.index("narrative_state") < labels.index("length_hint")
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
def test_live_sections_are_still_charged_to_the_budget(story):
+49 -23
View File
@@ -14,7 +14,7 @@ from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
SCHEMA = GOLD_SCHEMA
@@ -64,10 +64,17 @@ def client(monkeypatch):
def _adv(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -163,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
# ---------------------------------------------------------------- switching
def test_switching_back_restores_that_attempt_state(client):
"""Each attempt records its own total, so switching between them shows
whether the state followed the narration back."""
ScriptedProvider.replies = [
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
tally_reply("You take a scratch.", 95),
tally_reply("You take a beating.", 60),
]
_play(client)
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
assert _adv(client.adv_id)[0]["gold"] == 95
_retry(client)
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 60
assert player["gold"] == 10 # rolled back, not stacked to 20
player, _document = _adv(client.adv_id)
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
last = _actions(client)[-1]
r = client.post(
@@ -180,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
assert r.status_code == 200, r.text
assert r.json()["text"].startswith("You take a scratch")
assert r.json()["take_index"] == 0
# The stats follow the narration back.
player, world_state = _adv(client.adv_id)
assert world_state["player"]["hp"] == 95
assert player["gold"] == 10
# The state follows the narration back.
assert _adv(client.adv_id)[0]["gold"] == 95
# And forward again.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 1})
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
def test_switching_updates_the_world_change_chips(client):
def test_switching_updates_the_state_summary_chips(client):
"""The chip under a message describes the take on screen.
M5 replaced the RPG world-change chips with the narrative-state summary;
what this test guards is unchanged — switching takes must change what the
chip says, or the reader is shown one attempt's prose beside another's
consequences.
"""
ScriptedProvider.replies = [
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
"A beating.\n```state\n{\"player.hp\": -40}\n```",
tally_reply("A scratch.", 5),
tally_reply("A beating.", 40),
]
_play(client)
_retry(client)
last = _actions(client)[-1]
assert last["world_changes"][0]["delta"] == -40
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
switched = _actions(client)[-1]["state_summary"]
assert any("5" in line for line in switched), switched
assert not any("40" in line for line in switched)
def test_cannot_switch_a_turn_the_story_moved_past(client):
@@ -260,7 +274,13 @@ def test_undo_removes_the_action_and_its_history(client):
assert _adv(client.adv_id)[0]["gold"] == 0
def test_editing_the_text_updates_the_live_variant(client):
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
§15.5 requires the original narration to be retained. At the tip that means
the correction joins the turn's attempts rather than replacing the words of
one, so the pager still reaches what was there before.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
@@ -268,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
json={"text": "Two, but better."})
# Page away and back: the edit must survive, not be reverted by the switch.
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
json={"index": 0})
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
live = _actions(client)[-1]
assert live["text"] == "Two, but better."
assert live["take_count"] == 3, "the correction is a third attempt"
assert live["take_index"] == 2
# The original is still reachable, and the edit survives paging away.
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 1})
assert _actions(client)[-1]["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 2})
assert _actions(client)[-1]["text"] == "Two, but better."
File diff suppressed because it is too large Load Diff
+115 -1
View File
@@ -1,4 +1,14 @@
"""Tests for undo and retry rolling back the shared `world_state`.
"""Tests for undo and retry rolling back the state a position left behind.
M5 made `narrative_state` the authoritative document and `world_state` the
legacy one, and the corrective pass added the narrative column to this file. The
review found it covering only the legacy column (Finding 8) — which is how the
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
to live in the very code this module exists to pin down, untested.
The two columns follow deliberately different NULL rules, and both are asserted
below: a missing narrative snapshot restores the empty document, a missing
legacy snapshot is left alone.
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
@@ -25,6 +35,7 @@ import pytest
from fastapi import HTTPException
from app import attempts, memorybank, models
from app.narrative import model as narrative_model
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@@ -335,3 +346,106 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)
# --------------------------------------------- the narrative document (M5)
#
# `narrative_state` is what the reader is shown and what the narrator is told,
# so these are the assertions that matter most. They were missing until the M5
# corrective pass.
def _document(**entities) -> dict:
"""A minimal but real narrative document."""
state = narrative_model.empty()
for key, name in entities.items():
state["entities"][key] = {
"name": name, "type": "character", "status": "active",
"aliases": [], "attributes": {}, "conditions": [],
"description": "", "location": None,
}
return state
def test_snapshot_outcome_records_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(mara="Mara")
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.narrative_state_after == narrative_model.empty()
def test_restore_state_puts_back_the_narrative_document(db):
_, adv = _make_adventure(db, {})
adv.narrative_state = _document(aldric="Aldric")
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
assert list(adv.narrative_state["entities"]) == ["mara"]
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
_, adv = _make_adventure(db, {})
node = models.Action(adventure_id=adv.id, type="ai", text="x",
narrative_state_after=_document(mara="Mara"))
attempts.restore_state(adv, node)
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
"""M5 review, Finding 3 — the rule this file failed to pin down.
A pre-M5 position established nothing, and arriving there has to say so.
Leaving the live document alone instead left a *later* position's entities
and facts standing while the reader was somewhere earlier, which is the one
thing the head/state invariant forbids.
"""
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
assert pre_m5.narrative_state_after is None
attempts.restore_state(adv, pre_m5)
assert adv.narrative_state == narrative_model.empty()
# The legacy column keeps the opposite rule, deliberately: nothing consults
# it, and blanking a running campaign's numbers would help no one.
assert adv.world_state == {"gold": 7}
def test_restore_state_of_nothing_changes_neither_column(db):
_, adv = _make_adventure(db, {"gold": 7})
adv.narrative_state = _document(mara="Mara")
attempts.restore_state(adv, None)
assert list(adv.narrative_state["entities"]) == ["mara"]
assert adv.world_state == {"gold": 7}
+13 -5
View File
@@ -26,7 +26,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
# A world-state schema, so the RPG layer is exercised rather than skipped.
SCHEMA = GOLD_SCHEMA
@@ -129,10 +129,17 @@ def _play(client, text="look around", type="do"):
def _state(adv_id):
"""The instrument, and the whole document behind it.
M5 moved the instrument from an RPG stat to a typed narrative fact; the
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
tally, and `[1]` is the authoritative state document.
"""
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}), adv.world_state
state = adv.narrative_state or {}
return {"gold": tally_of(state)}, state
finally:
db.close()
@@ -186,9 +193,10 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
assert "go north" in story
def test_scripts_run_once_per_turn(client):
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
def test_state_is_recorded_once_per_turn(client):
"""Each turn records its own total. A turn whose state ran twice would show
a value no reply ever stated."""
ScriptedProvider.replies = [tally_reply("One.", 10), tally_reply("Two.", 20)]
_play(client)
_play(client)
script_state, _ = _state(client.adv_id)
+27 -16
View File
@@ -24,14 +24,17 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import (
GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of,
tally_reply,
)
SCHEMA = GOLD_SCHEMA
# Ten gold a turn, every turn, banked through the world-state engine. A number
# that only ever increases makes a rollback failure obvious: if a take stacks
# instead of replacing, the gold total is off by exactly one turn's worth. See
# `fakes.gold_reply` for why this is not a script any more.
# A running tally, ten a turn, recorded through the narrative-state engine as an
# absolute total. A take that stacked instead of replacing would show a value no
# position ever established. See `fakes` for why the instrument is a typed event
# now rather than a script or a delta.
@pytest.fixture()
@@ -47,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Vault", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -95,7 +97,7 @@ def _take(client, action_id, text=""):
def _gold(adv_id) -> int:
db = SessionLocal()
try:
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -124,18 +126,21 @@ def test_the_script_runs_once_a_turn(client):
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
"""Two turns played, then the first one taken again.
The take leaves the path just before turn one. The state it starts from
is the state turn one started from: zero gold, not the twenty that two
turns accumulated. The take's own run then adds ten.
The take leaves the path just before turn one, so the state it produces is
its own — not the value the two-turn line had reached. Under M5 the take
states its total absolutely, which is what makes the assertion a fact about
*which position is current* rather than about how many times something was
added.
"""
_play(client)
_play(client, "press on")
assert _gold(client.adv_id) == 20
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10, "rolled back to before that turn, then run once"
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
@@ -144,28 +149,33 @@ def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
assert _gold(client.adv_id) == 20
first_player = _rows(client.adv_id, "do")[0]
ScriptedProvider.replies = [tally_reply("A different opening.", 10)]
_take(client, first_player.id, "> You do something else.")
assert _gold(client.adv_id) == 10
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
"""The `after_id` path, which forks while writing.
The take being written under produced a state of ten gold, from its own
turn. The turn played on top of it adds another ten. The twenty gold
that the abandoned line reached has no effect on this branch.
Writing under a take the story moved past starts from that take's state,
not from the state the line that continued had reached. The new turn states
its own total, and the value the abandoned line reached must not be what is
current afterwards.
"""
_play(client)
ScriptedProvider.replies = [tally_reply("A second telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
ScriptedProvider.replies = [tally_reply("Onward.", 20)]
_play(client, "press on")
assert _gold(client.adv_id) == 20
discarded = [a for a in _rows(client.adv_id, "ai") if not a.live][0]
ScriptedProvider.replies = [tally_reply("A different way.", 55)]
_play(client, "a different way", after_id=discarded.id)
assert _gold(client.adv_id) == 20, "that take's ten, plus this turn's ten"
assert _gold(client.adv_id) == 55, "this branch's own state"
def test_the_line_left_behind_keeps_the_state_it_reached(client):
@@ -173,6 +183,7 @@ def test_the_line_left_behind_keeps_the_state_it_reached(client):
_play(client)
_play(client, "press on")
first_ai = _rows(client.adv_id, "ai")[0]
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
_take(client, first_ai.id)
assert _gold(client.adv_id) == 10
+10 -2
View File
@@ -119,7 +119,14 @@ def pre_tree():
# Dropping the tables is the only way to remove the columns.
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
for table in ("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))
@@ -592,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 ("actions", "memories", "branches", "adventures"):
for table in ("state_events", "state_proposals", "checkpoints",
"actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
+20 -8
View File
@@ -18,7 +18,7 @@ from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
from fakes import tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies
@@ -46,7 +46,10 @@ def client(monkeypatch):
setup.close()
# Force a real, non-demo turn that uses the fake provider.
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
# Each turn states its own running total, absolutely (ADR 010). One
# repeated reply would set the same total every turn, which is exactly
# what a delta protocol could not distinguish from adding.
ScriptedProvider.replies = tally_replies(AI_REPLY, 20)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
@@ -66,12 +69,15 @@ def client(monkeypatch):
def _gold(adv_id) -> int:
"""The banked total. Reading one stat rather than the whole section keeps
these assertions about the rollback, not about the schema's shape."""
"""The banked total, read out of the authoritative narrative state.
M5 moved the instrument from an RPG stat to a typed narrative fact. What
these assertions are about is unchanged: rollback, and which position's
state is current.
"""
db = SessionLocal()
try:
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
return player.get("gold", 0)
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
finally:
db.close()
@@ -102,11 +108,16 @@ def test_two_turns_then_undo_reverts_only_last(client):
def test_retry_does_not_double_apply_gold(client):
"""A retry replaces its turn rather than stacking on top of it.
The retake states the same total the turn it replaces did, so a state that
had stacked would read as a value no position ever established. Before the
fix this file was written for, the turn's effects ran twice.
"""
_play(client)
assert _gold(client.adv_id) == 10
# Before the fix, this produced 20 because the turn's effects ran twice.
# Now it stays 10.
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _gold(client.adv_id) == 10
@@ -114,6 +125,7 @@ def test_retry_does_not_double_apply_gold(client):
def test_retry_then_undo_still_clean(client):
_play(client)
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
client.post(f"/api/adventures/{client.adv_id}/retry")
assert _gold(client.adv_id) == 10
client.post(f"/api/adventures/{client.adv_id}/undo")
+82 -46
View File
@@ -1,6 +1,32 @@
"""End-to-end HTTP test for RPG world state (Phase 12): a scenario with a
stat_schema, a turn whose (faked) AI reply carries a state delta block, and
undo rolling the world state back.
"""M5: the RPG world state is demoted, and this file is what holds it there.
This was the end-to-end test for Phase 12's RPG world state: a scenario with a
`stat_schema`, a turn whose reply carried a relative-delta block, the referee
applying it, and undo rolling it back.
**M5 removed that pipeline from the turn path** (ADR 010). Relative deltas were
ambiguous by construction — a number in a delta field is syntactically legal
whether the model meant "add 50" or "set to 50" — and the genre-neutral typed
events in `app/narrative/` replaced them. The engine in `app/worldstate/` is
retained so a pre-M5 database opens unchanged and its numbers stay readable, and
its own logic is still covered by `test_worldstate.py`; what no longer exists is
its authority over new turns.
So this file now tests the demotion, which is worth a test precisely because it
is an absence: nothing else would notice if the delta pipeline were quietly
rewired, and a second authoritative state engine is the specific failure the M5
brief names.
What was deleted, and why, rather than being re-instrumented:
* `test_turn_applies_clamped_delta_and_strips_block`, `test_action_world_changes_summary`,
`test_world_state_endpoint`, `test_undo_reverts_world_state`,
`test_retry_does_not_double_apply` — each asserted that a turn applies a
delta through the referee. That is the removed capability itself, not
instrumentation for something else. The invariants they *also* touched —
undo restoring state, a retry not double-applying — are alive and better
covered in `test_head_cursor.py`, `test_take_state.py` and
`test_narrative_state.py`, now measured through the production state path.
python -m pytest tests/test_worldstate_integration.py -v
"""
@@ -103,47 +129,74 @@ def _play(client, text="attack the goblin"):
return r
def test_turn_applies_clamped_delta_and_strips_block(client):
def test_a_turn_no_longer_applies_a_relative_delta_block(client):
"""The demotion, asserted.
The fixture's reply carries the old protocol. Under Phase 12 this moved hp
from 100 to 70. Under M5 the referee is not in the turn path at all, so the
numbers stay where they were — and the block still leaves the prose, because
a reader must never see the protocol whichever protocol it is.
"""
_play(client)
ws = _world(client.adv_id)
assert ws["player"]["hp"] == 70 # -80 capped to -30
assert ws["npc"]["gwen"]["trust"] == 15
assert ws["flags"]["alarm"] is True
assert ws["milestones"]["win"]["reached"] is True
# The state block is not shown to the player.
assert "```state" not in _last_ai_text(client.adv_id)
assert "goblin's blade" in _last_ai_text(client.adv_id)
# The raw model reply, including the block, is kept for the Insights view.
world = _world(client.adv_id)
assert world["player"]["hp"] == 100, "the delta pipeline is still wired into turns"
assert world.get("milestones", {}) == {}
assert world.get("flags", {}) in ({}, {"alarm": False})
text = _last_ai_text(client.adv_id)
assert "```state" not in text, "the protocol reached the reader"
assert "goblin's blade" in text
# The raw reply is still kept, so the Insights view and the audit can show
# what the model actually sent.
db = SessionLocal()
try:
snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
snapshot = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
finally:
db.close()
assert "```state" in snap["raw_output"]
assert '"player.hp": -80' in snap["raw_output"]
assert '"player.hp": -80' in snapshot["raw_output"]
def test_action_world_changes_summary(client):
def test_an_old_style_block_proposes_no_narrative_events(client):
"""A delta block is not a typed proposal, and must not be read as one.
`{"player.hp": -80}` is a JSON object with no `events` list. It parses, so
it is not malformed; it simply proposes nothing. What matters is that no
part of it is coerced into an event — a state engine that guessed here would
reintroduce exactly the ambiguity ADR 010 removed.
"""
_play(client)
db = SessionLocal()
try:
changes = db.get(models.Adventure, client.adv_id).actions[-1].world_changes
proposal = (
db.query(models.StateProposal)
.filter_by(adventure_id=client.adv_id)
.order_by(models.StateProposal.id.desc())
.first()
)
assert proposal is not None, "no proposal was recorded for the turn"
assert db.query(models.StateEvent).filter_by(
adventure_id=client.adv_id).count() == 0
finally:
db.close()
by_label = {c["label"]: c for c in changes}
assert by_label["hp"]["delta"] == -30 # clamped stat, signed delta
assert by_label["gwen trust"]["delta"] == 15 # npc.<id>.<stat> -> "id stat"
assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True}
assert by_label["win"]["kind"] == "milestone"
def test_world_state_endpoint(client):
_play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
def test_the_narrative_state_is_what_the_turn_now_establishes(client):
"""And the replacement works on the same campaign, in the same turn shape."""
ScriptedProvider.replies = [
'The blade turns aside.\n```state\n{"events": ['
'{"type": "create_entity", "entity": "gwen", "entity_type": "character",'
' "name": "Gwen"},'
'{"type": "set_entity_status", "entity": "gwen", "status": "wary"}'
']}\n```'
]
_play(client, "parry")
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
body = r.json()
assert body["schema"]["player"]["hp"]["max"] == 100
assert body["state"]["player"]["hp"] == 70
document = r.json()["document"]
assert document["entities"]["gwen"]["status"] == "wary"
def test_override_world_state_endpoint(client):
@@ -160,20 +213,3 @@ def test_override_world_state_endpoint(client):
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
assert r.json()["state"]["player"]["hp"] == 100
def test_undo_reverts_world_state(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/undo")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 100 # back to initial
assert _world(client.adv_id)["milestones"] == {}
def test_retry_does_not_double_apply(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
assert _world(client.adv_id)["player"]["hp"] == 70 # not 40
+19 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks } from './branches'
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches'
// The story tree, drawn.
//
@@ -108,6 +108,9 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
// would throw away the only copy of it.
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
// Save Points naming a moment on the selected branch, or on anything forked
// from it, keep it alive — the server refuses to delete them along with it.
const protecting = selected ? savePointsUnder(branches, selected.id) : 0
return createPortal(
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
@@ -239,7 +242,13 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
{confirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
{/* The same wording the list gives, because the same deletion
is reachable from both views and a rule that held in one of
them would not be a rule. */}
<span>
Delete this branch and everything forked from it? The story on
other paths, and every Save Point, is unaffected.
</span>
<button type="button" className="danger" disabled={busy} onClick={drop}>
Delete
</button>
@@ -265,10 +274,14 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
the head stands on is offered, and says why it cannot go. */}
{!isRoot && (
<button type="button" className="danger"
disabled={busy || isLoadBearing}
title={isLoadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
disabled={busy || isLoadBearing || protecting > 0}
title={
isLoadBearing
? 'The line you are reading is built on this one. Switch away first.'
: protecting > 0
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
: undefined
}
onClick={() => setConfirming(true)}>Delete</button>
)}
</div>
+30
View File
@@ -106,6 +106,36 @@ 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
// is. Restore answers with the story as it now stands, like a branch switch,
// so the caller replaces its window rather than reloading everything.
listCheckpoints: (advId) => request(`/adventures/${advId}/checkpoints`),
createCheckpoint: (advId, name, note = '') =>
request(`/adventures/${advId}/checkpoints`, {
method: 'POST', body: JSON.stringify({ name, note }),
}),
renameCheckpoint: (advId, checkpointId, name) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, {
method: 'PATCH', body: JSON.stringify({ name }),
}),
deleteCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, { method: 'DELETE' }),
restoreCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}/restore`, { method: 'POST' }),
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
// own takes the text given. Streams, because it is a turn like any other.
//
+35
View File
@@ -61,6 +61,41 @@ export function headLineage(branches) {
return out
}
// How many Save Points would go if this branch were deleted.
//
// Deleting a branch deletes everything forked from it, and a Save Point names a
// position on a line, so the Save Points on the whole doomed subtree go too.
// The count is summed over that subtree rather than over the one branch — a
// warning that said "1 Save Point" while three disappeared would be worse than
// no warning at all.
//
// Computed here because the panel already holds every branch and its parent,
// so the answer costs a walk rather than an endpoint. The server remains the
// authority on what is actually deleted; this only lets the button say so
// before it is pressed, the same division `headLineage` already uses.
export function savePointsUnder(branches, rootId) {
const children = new Map()
for (const b of branches) {
const key = b.parent_branch_id
if (!children.has(key)) children.set(key, [])
children.get(key).push(b)
}
const byId = new Map(branches.map((b) => [b.id, b]))
const seen = new Set()
const stack = [rootId]
let total = 0
while (stack.length) {
const id = stack.pop()
// The guard is against a cycle, which the schema forbids and a walk should
// still never hang on.
if (seen.has(id)) continue
seen.add(id)
total += byId.get(id)?.save_points ?? 0
for (const child of children.get(id) ?? []) stack.push(child.id)
}
return total
}
// ---------- Map geometry ----------
export const ROW_H = 64 // one branch, name above the lane and meta below
+53 -3
View File
@@ -22,6 +22,8 @@ import { BranchPanel } from './panels/BranchPanel'
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']
@@ -68,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' | '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)
@@ -452,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
@@ -463,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 })
}
@@ -539,12 +554,16 @@ 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' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'savepoints' ? 'active' : ''}
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
@@ -714,6 +733,13 @@ export default function Play() {
from here: that future is retained, but it is no longer the
continuation this story tells. */}
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
{/* Save Point sits with the history controls because that is what
it is: a name for a position Undo and Redo move between. The
button opens the panel, where the name is typed — the moment it
saves is wherever the story is being read, so there is nothing
to choose first. */}
<button onClick={() => setPanel('savepoints')} disabled={busy}
title="Name this moment so you can come back to it">⚑ Save Point</button>
</div>
<div className="input-bar">
<div className="mode-select">
@@ -752,10 +778,23 @@ export default function Play() {
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<h2>{{
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' ? (
@@ -764,6 +803,17 @@ export default function Play() {
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'savepoints' ? (
// Restoring one moves the story exactly as Undo and Redo do, so it
// adopts the returned window the same way a branch switch does —
// the state panels are showing another position's numbers until
// they re-read.
<SavePointPanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onRestored={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'branches' ? (
<BranchPanel
advId={id}
+26 -6
View File
@@ -11,7 +11,7 @@
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { BranchMap } from '../../../BranchMap'
import { branchLabel, headLineage, orderBranches } from '../../../branches'
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
const [branches, setBranches] = useState(null)
@@ -88,6 +88,12 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
// was forked from. The button said nothing about that and answered
// with a toast; it now says so before it is pressed.
const loadBearing = lineage.has(branch.id)
// A Save Point naming a moment on this branch, or on anything forked
// from it, keeps the branch alive: named moments last until the user
// deletes them, so the server refuses the deletion rather than taking
// them with it. Counted over the subtree, because deleting a branch
// takes its descendants.
const protecting = savePointsUnder(branches, branch.id)
return (
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
style={{ marginLeft: indent * 12 }}>
@@ -117,11 +123,20 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
{branch.own_actions} of its own
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
{` · ends at ${branch.depth + 1}`}
{protecting > 0 &&
` · ${protecting === 1 ? '1 Save Point' : `${protecting} Save Points`} kept here`}
</div>
{isConfirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
{/* Only reachable when no Save Point points into this
subtree — the button is disabled otherwise, and the server
refuses regardless. So this says what the deletion costs
without hedging about Save Points that cannot be at risk. */}
<span>
Delete this branch and everything forked from it? The story
on other paths, and every Save Point, is unaffected.
</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(branch)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
@@ -145,10 +160,15 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
{/* The root holds the turns every other branch borrows, and
the server refuses it — so it is not offered. */}
{branch.parent_branch_id !== null && (
<button type="button" className="danger" disabled={busy || loadBearing}
title={loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
<button type="button" className="danger"
disabled={busy || loadBearing || protecting > 0}
title={
loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: protecting > 0
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
: undefined
}
onClick={() => setConfirming(branch.id)}>Delete</button>
)}
</div>
@@ -0,0 +1,209 @@
// Save Points: naming a place in the story, and going back to one.
//
// "Save Point" is the word throughout, and the words branch, head, node and
// fork appear nowhere a player can read (`BROWSER-UX-SPEC.md` §23). The panel
// is deliberately small — a form, a list, and two confirmations. It is not a
// branch explorer, and the tree it sits over stays out of sight.
//
// Both confirmations exist to say what does *not* happen, because that is the
// part a player cannot see and would otherwise assume the worst about: restore
// keeps the later story, and deleting a Save Point deletes no story at all.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
function SavePointPanel({ advId, refreshKey, onRestored, onError }) {
const [points, setPoints] = useState(null)
const [failed, setFailed] = useState(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [busyId, setBusyId] = useState(null)
const [renaming, setRenaming] = useState(null) // { id, text }
const [confirming, setConfirming] = useState(null) // { id, kind }
const [tick, setTick] = useState(0)
useEffect(() => {
let cancelled = false
setFailed(null)
api.listCheckpoints(advId)
.then((list) => { if (!cancelled) setPoints(list) })
.catch((err) => { if (!cancelled) setFailed(err.message) })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
// Answers whether it worked, so a caller can keep its editor open on a
// refusal — a rename the server turned down must not take the typed name
// with it.
async function run(id, work) {
setBusyId(id)
try {
await work()
setTick((t) => t + 1)
return true
} catch (err) {
onError(err.message)
return false
} finally {
setBusyId(null)
}
}
async function create(e) {
e.preventDefault()
if (!name.trim() || saving) return
setSaving(true)
try {
await api.createCheckpoint(advId, name.trim(), note.trim())
setName('')
setNote('')
setTick((t) => t + 1)
} catch (err) {
onError(err.message)
} finally {
setSaving(false)
}
}
const restore = (p) => run(p.id, async () => {
onRestored(await api.restoreCheckpoint(advId, p.id))
setConfirming(null)
})
const rename = async (p) => {
if (await run(p.id, () => api.renameCheckpoint(advId, p.id, renaming.text))) {
setRenaming(null)
}
}
const remove = async (p) => {
if (await run(p.id, () => api.deleteCheckpoint(advId, p.id))) setConfirming(null)
}
return (
<div className="save-point-panel">
<form className="save-point-new" onSubmit={create}>
<label htmlFor="save-point-name">Save this moment</label>
<input
id="save-point-name"
autoFocus
maxLength={120}
placeholder="Before entering the abbey"
value={name}
disabled={saving}
onChange={(e) => setName(e.target.value)}
/>
<input
className="save-point-note"
maxLength={500}
placeholder="A note, if you want one (optional)"
value={note}
disabled={saving}
onChange={(e) => setNote(e.target.value)}
/>
<button type="submit" className="primary" disabled={saving || !name.trim()}>
Save Point
</button>
<p className="save-point-hint">
Saves the moment you are reading now. If you have stepped back, that
is the moment it saves.
</p>
</form>
{failed && <div className="panel-empty">Couldn’t read the Save Points — {failed}</div>}
{!failed && !points && <div className="panel-empty">Reading your Save Points…</div>}
{!failed && points?.length === 0 && (
<p className="save-point-intro">
No Save Points yet. Name a moment you might want to come back to, then
keep playing — going back to it later leaves everything you wrote
after it in place.
</p>
)}
<div className="save-point-list">
{(points || []).map((p) => {
const isRenaming = renaming?.id === p.id
const busy = busyId === p.id
const confirm = confirming?.id === p.id ? confirming.kind : null
return (
<div key={p.id} className={`save-point-row ${p.on_path ? '' : 'elsewhere'}`}>
<div className="save-point-head">
{isRenaming ? (
<input
className="save-point-rename"
autoFocus
maxLength={120}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') rename(p)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="save-point-name">{p.name}</span>
)}
</div>
<div className="save-point-meta">
Moment {p.turn}
{/* Said plainly, without naming a branch: the story took a
different turning after this point, and going back to it
returns to the telling it was saved in. */}
{!p.on_path && p.resolved && ' · on a path you left'}
{!p.resolved && ' · this moment is no longer in the story'}
</div>
{p.note && <div className="save-point-note-text">{p.note}</div>}
{confirm === 'restore' ? (
<div className="save-point-confirm">
<span>
The story will return to this Save Point. Everything you
wrote after it is kept — it just stops being where you are.
</span>
<button type="button" className="primary" disabled={busy}
onClick={() => restore(p)}>Restore</button>
<button type="button" onClick={() => setConfirming(null)}>Cancel</button>
</div>
) : confirm === 'delete' ? (
<div className="save-point-confirm">
<span>
Delete this Save Point? Deleting it does not delete any of
the story — only the name you gave this moment.
</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(p)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
</div>
) : (
<div className="save-point-tools">
<button type="button" disabled={busy || !p.resolved}
title={p.resolved ? undefined
: 'The moment this Save Point named is no longer in the story.'}
onClick={() => setConfirming({ id: p.id, kind: 'restore' })}>
Restore
</button>
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => rename(p)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: p.id, text: p.name })}>
Rename
</button>
)}
<button type="button" className="danger" disabled={busy}
onClick={() => setConfirming({ id: p.id, kind: 'delete' })}>
Delete
</button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export { SavePointPanel }
@@ -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 }
+5 -4
View File
@@ -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} />
+186
View File
@@ -255,6 +255,102 @@
flex-basis: 100%;
}
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
Point is a name for a position, and it should not look like a different
species of thing from the lines it names positions on. The confirmations
borrow the branch panel's shape but not its danger colouring — restoring
destroys nothing, and only deletion is red. */
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
.save-point-new label {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
}
.save-point-new input {
padding: 5px 9px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-new input.save-point-note {
font-size: 0.82rem;
}
.save-point-hint, .save-point-intro {
margin: 0;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.55;
}
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
.save-point-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
than hidden: it still restores, and hiding it would be the automatic cleanup
this milestone deliberately does not do. */
.save-point-row.elsewhere { opacity: 0.72; }
.save-point-head { display: flex; align-items: center; gap: 7px; }
.save-point-name {
font-family: var(--font-story);
font-size: 0.98rem;
color: var(--text);
}
.save-point-rename {
flex: 1;
min-width: 0;
padding: 3px 7px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-meta {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.save-point-note-text {
font-size: 0.78rem;
color: var(--text-dim);
line-height: 1.5;
}
.save-point-tools, .save-point-confirm {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.save-point-tools button, .save-point-confirm button {
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.save-point-tools button:hover:not(:disabled),
.save-point-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.save-point-tools button.danger:hover:not(:disabled),
.save-point-confirm button.danger:hover:not(:disabled) {
color: var(--danger);
border-color: var(--danger);
}
.save-point-tools button:disabled,
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
.save-point-confirm span {
font-size: 0.74rem;
color: var(--text-dim);
line-height: 1.5;
flex-basis: 100%;
}
.action-edit { margin-bottom: 14px; }
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
several paragraphs, and the old fixed 110px turned that into a keyhole.
@@ -314,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;
}
+7 -1
View File
@@ -358,10 +358,16 @@ Each entry:
```text
Before entering the abbey
Turn 42
Moment 42
[Restore] [Rename] [Delete]
```
**Moment, not Turn.** M4 closeout ruled in favour of the implemented product:
the branch panel and the tree overlay already count in moments (`forked at
moment 9`, `ends at moment 40`), so a Save Point list saying "Turn 42" would
make one screen use two words for one thing. This is a vocabulary alignment and
changes no behaviour; the number is unchanged.
## 26. Restore Confirmation
Restoring is non-destructive.
+175 -10
View File
@@ -1,6 +1,6 @@
# Adventure Storyteller — Production Build Milestones
**Status:** In implementation. M1 and M2 complete (2026-09-02); M3 next
**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
@@ -250,7 +250,7 @@ History operations are non-destructive, Redo works, divergence preserves old fut
## Status: COMPLETE
Accepted 2026-09-03. Evidence: `planning/reports/M3-IMPLEMENTATION-REPORT.md`,
Accepted 2026-09-03. Evidence: `planning/archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`,
which is M3's primary evidence record — no separate baseline report was produced,
so that document carries the raw counts and runtime observations as well as the
review. The architecture is recorded in **ADR 012**.
@@ -274,14 +274,18 @@ review. The architecture is recorded in **ADR 012**.
a turn says while story descends from it off screen — both ratified in
`STORY-BRANCH-SEMANTICS.md` (§5, §10, §14A).
**Outstanding closeout condition:** the required **browser smoke test has not
been performed** — no session in which M3 was implemented or reviewed had a
browser available. The equivalent sequence was driven end-to-end against the
running application with real inference and a process restart, and every
server-side behaviour it covers passes; the DOM-level behaviour of the Redo
button, its disabled states and its keyboard shortcut remain unverified by
observation. This does not block M4, which touches none of that wiring, but it
remains an open M3 item until a human runs it.
**Closeout condition — CLOSED at M4 closeout (2026-09-03).** M3 was accepted with
one condition outstanding: the required **browser smoke test had not been
performed**, because no session in which M3 was implemented or reviewed had a
browser available, leaving the DOM-level behaviour of the Redo button and its
disabled states unverified by observation.
That condition is now discharged. A real Firefox 154.0.1, driven through
geckodriver, exercised M3's controls in the rendered application: Undo enabled
and Redo disabled at the tip, two Undos moving the transcript back, Redo becoming
enabled and returning the original tip exactly, Retry and the take pager, and a
divergent write retiring Redo with no stale old-future text on screen. It passed.
Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md` §W.7.
**Debt carried forward, none of it blocking M4:** full narrator-edit state
re-evaluation is deferred to M5 (`STORY-BRANCH-SEMANTICS.md` §14A records the
@@ -351,6 +355,121 @@ M3's cost was reconciling them; a parallel checkpoint mover would recreate that
divergence in a place where the two paths would silently disagree about what
"restore" means. See ADR 012.
## Status: COMPLETE
Accepted 2026-09-03. Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md`,
including its §W closeout addendum. The Definition of Done is met, and — for the
first time in this project — **verified in a real browser**.
**What M4 delivered:**
- **`checkpoints`**, a table holding a name, an optional note, and a
`(branch, depth)` coordinate — and no copy of any story. `create_all` builds
it, as it did `memories` and `branches`; migration 80 adds the index. No
backfill: nobody had named a position before M4, and inventing one would be
inventing the decision.
- **Create / list / rename / delete / restore** under
`/api/adventures/{id}/checkpoints`, campaign-scoped, with a Save Point from
another campaign a 404 rather than a restore of the wrong story.
- **Create at the active head, not the retained tip**, so a Save Point made after
two Undos names the undone position.
- **Restore that delegates**, and is the whole of the milestone's architecture:
resolve the coordinate, refuse it if it names no live turn, then
`head.move_to_node` — one function whose depth half is M3's `head.move_to`
unchanged, and whose branch half is the single assignment `switch_branch`
makes. Restore forks nothing.
- **A browser Save Point panel** — create form, list, Restore, Rename, Delete,
with both confirmations saying what is *not* destroyed — plus a Save Point
button beside Undo and Redo, where it belongs. No branch explorer, no
discarded-history browser, no merge UI.
- **Export/import of Save Points** with no format version bump, and a pre-M4
bundle importing with none.
**The one architectural decision M4 had to make**, which ADR 012 does not settle:
a Save Point can name a position on a line the story has since left, so restore
moves the branch half of the head as well — but *only* when the coordinate is not
on the path being read. Doing it unconditionally would quietly hand back an
abandoned continuation whenever a Save Point in a shared prefix was restored. No
new ADR: this is ADR 012's mechanism applied to both halves of a coordinate ADR
012 already defines, not a new architecture. `TECHNICAL-DESIGN.md` §8.8 records
it.
**Tests:** 42 in `backend/tests/test_save_points.py`, covering D11-D14, I04, L03,
E-series lineage and memory isolation after restore and divergence, the edge
cases in the brief, and the M3-database migration.
**Facts M5 inherits, and must not redesign:**
- a Save Point is a **durable story coordinate** — `(branch, depth)` — carrying
no copy of transcript, state, prompt, memory or summary;
- **restore is M3 head movement**, through the same `head.move_to` Undo and Redo
use; there is no second restore path and M5 must not add one;
- **state recovery stays snapshot/cached-position based**, never a replay of the
campaign (`TECHNICAL-DESIGN.md` §10.4). This is now load-bearing for Save
Points as well as Undo/Redo;
- **the first divergent write** after a restore creates the continuation;
restore itself never forks;
- **history a Save Point names cannot disappear** through an unrelated deletion
(§19.1);
- **M3/M4 history and Save Point semantics are infrastructure now.** M5 replaces
the state *model*; it does not revisit how the story is positioned.
**Acceptance evidence:** D11-D14, I04 and L03 all pass. D11 and L03 are
discharged by automation that crosses a **genuine OS process boundary** — one
server process writes the campaign, is killed, and a second process reads it back
— rather than by recreating a client in one process.
**The browser condition is closed, for M4 and retrospectively for M3.** A real
Firefox 154.0.1, driven through geckodriver over the W3C WebDriver protocol,
exercised the rendered DOM end to end: **44/44 checks passed**, covering M3's
Undo/Redo enable states and transcript movement, Retry and the take pager, M3
divergence and the disappearance of Redo, and every M4 Save Point operation
including both confirmations and the branch-delete warning. No console errors.
This closes the outstanding M3 condition recorded above and the equivalent M4
one.
**The three review findings were fixed during closeout:**
- **B-1** — the Save Point list was an N+1 that loaded whole `Action` rows,
narration included. It is now one bulk two-column coordinate query plus one
lineage: **53 SELECTs for 25 Save Points became 5**, and the query count no
longer moves with the length of the list.
- **B-2** — deleting a branch silently deleted the Save Points naming it. Fixed
as a **behaviour** defect, not a wording one: a branch a Save Point names can
no longer be deleted at all. The request is refused with the offending Save
Points named, the user deletes them explicitly (which deletes no story), and
the branch then goes. Both delete controls, in the branch list and in the tree
overlay, disable and explain rather than warning about a loss that no longer
happens. `STORY-BRANCH-SEMANTICS.md` **§19.1** records the rule; §28 already
required a future cleanup feature to retain checkpoint-referenced paths, and
this is that requirement applied to the deletion path that exists today.
- **B-3** — the D11/L03 automation now spawns real server processes.
**Also fixed:** creating a Save Point takes the campaign's turn lock (review §S
C-5), so "save where I am" cannot read a head that a turn in flight is about to
move. Rename and Delete deliberately do not take it — neither reads nor moves a
story position.
**Debt M4 carries forward:** the Save Point panel has no frontend test, because
the project still has no frontend test runner at all (M8) — the browser smoke
test above is a closeout procedure, not a suite. `POST /adventures/import` still
returns every branch's rows rather than a head-capped window (inherited, M3).
## Note to M5 — the instrumentation is now larger than M3 estimated
M4 added **60 tests that use inherited RPG world-state values as deterministic
instrumentation**, on top of the ~20 M3 flagged. M5 replaces that state model,
and must **move the instrumentation while preserving the behavioural
assertions**: what those tests measure is where the story is being read and what
state belongs to that position, which is exactly as true after M5 as before it.
Deleting them would delete the evidence for D11-D14, I04, L03 and the E-series.
The existing constraint stands and is now load-bearing for Save Points as well as
Undo/Redo: **historical state must remain efficiently snapshot/cache
recoverable**, so that moving to a position never becomes proportional to
campaign length (`TECHNICAL-DESIGN.md` §10.4). Restore, Undo and Redo all pay
whatever that costs.
---
# M5 — Genre-Neutral Authoritative Narrative State
@@ -472,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
+65 -1
View File
@@ -210,6 +210,66 @@ checkpoint:
A checkpoint is a named pointer to a recoverable story position. It should normally remain tied to the turn where it was created. Restoring it moves the campaign active head; it does not delete later retained history. A new branch is created on the first divergent write after restore, not merely because the checkpoint was opened.
As implemented in M4, the pointer is a **coordinate rather than a turn id**:
`(branch_id, depth)`, which is the same pair §4 records as the campaign's active
head and which `head.node_at` resolves. This is the equivalence §4 already draws
between a turn reference and a branch-plus-depth, applied to the same position
from the other end, and it is not a shortcut — it is the more correct pointer of
the two for this data model:
- **A coordinate follows a retry; a row id does not.** One coordinate holds
every attempt at a turn and exactly one of them is live (§7). A Save Point
names the turn, so it must land on whichever take the story currently tells.
Pinning the row would leave the pointer on a superseded attempt the reader
cannot see.
- **The user-facing term is Save Point**; `checkpoint` remains the internal name
(`BROWSER-UX-SPEC.md` §23).
The row carries the name, an optional note, the coordinate, and its timestamps.
It carries **no** copy of the transcript, the state, the prompt, a memory, a
summary, or a branch's contents. Everything a restore produces comes from the
retained history the coordinate points into.
The retry case is what settles the coordinate-versus-turn-id question, and M4
closeout measured it rather than arguing it. A Save Point named a turn whose
live row was id 17; retrying that turn made id 17 dead and id 18 live at the
same coordinate; the Save Point resolved to id 18 and restored correctly. A row
id would have pinned a take the story no longer tells. **A Save Point names a
story position, not a particular take of it.**
Three further properties of the implemented model, recorded so they are decided
rather than incidental:
- **Names are not unique**, and nothing requires them to be. No product
requirement asks for uniqueness, and two names for one moment is a reasonable
thing for a player to want.
- **Several Save Points may name the same position.** Same reason.
- **Ordinary list presentation is newest-created first.** Story order is not
something the list can honestly claim: depths on lines that have parted
company are not comparable, so ordering by depth would draw a sequence that no
reading of the story passes through. When each was made is a fact about all of
them.
None of this is genre-specific. A coordinate is a position in a story; what the
state at that position *contains* is M5's question, and changing it does not
change what a Save Point is.
Two consequences worth recording here:
- **Restore reuses the campaign's one head-movement mechanism.** It resolves the
coordinate and moves the head; nothing is reconstructed and nothing is
deleted. The branch half of the head moves only when the coordinate is not on
the path being read, which is what makes a Save Point on a departed line
restorable at all — and what keeps a Save Point in a shared prefix from
dragging the reader off the line they chose. See `TECHNICAL-DESIGN.md` §8.8.
- **A checkpoint is durable against everything but its own explicit deletion.**
No pass removes one for going stale, sitting behind the head, or naming a line
the story left (`STORY-BRANCH-SEMANTICS.md` §19). Deleting a *branch* does not
remove one either: the deletion is refused while a checkpoint names any
position in the subtree, and the user deletes the checkpoint first
(§19.1). Deleting the whole campaign removes them, which is what deleting a
campaign means.
## 9. Narrative Entity
An entity is a persistent thing or concept in the fictional world.
@@ -684,7 +744,11 @@ The physical container format remains an implementation choice, but the export m
As implemented in M3, the export carries the active branch, the active head
position on it, and each branch's disposition, alongside the whole retained turn
graph. The governing rule for this package is that an export carries what was
graph. **M4 added the checkpoints**, by the same rule: a position someone chose
to name cannot be recomputed from the turns, because nothing about a turn records
that it was bookmarked. The head and the checkpoints stay independent on import —
a campaign opens where its head says, never at a checkpoint merely because one is
in the file. The governing rule for this package is that an export carries what was
*chosen* and recomputes what is *derived* — and the active head moved from the
second category to the first, because once Undo stops deleting, two campaigns
with identical turns can be being read at different positions and no import can
@@ -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.
+7 -7
View File
@@ -82,16 +82,16 @@ in `planning/archive/decisions/`.
One file, and it changes as development progresses:
```text
planning/reports/M3-IMPLEMENTATION-REPORT.md
planning/reports/M4-IMPLEMENTATION-REPORT.md
```
M3 is the most recently completed milestone, and M4 is the next to be briefed.
This report is M3's review *and* its primary evidence record — no separate M3
baseline report was produced — so it is the only place some of what M3 left
behind is written down, including the browser smoke test M3 still owes.
M4 is the most recently completed milestone, and M5 is the next to be briefed.
This report is M4's review *and* its closeout record: its §W holds the corrective
work, the process-boundary automation, and the real-browser verification that
closed the condition M3 and M4 both carried.
**Replace it, do not accumulate.** When M4's report lands, remove this one from
the project Sources and upload M4's instead. The repository does the same thing:
**Replace it, do not accumulate.** When M5's report lands, remove this one from
the project Sources and upload M5's instead. The repository does the same thing:
`planning/reports/` holds the current milestone's report and
`planning/archive/milestone-reports/` holds the rest.
+24 -22
View File
@@ -3,9 +3,10 @@
**This file is the index. Start here.**
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
milestones **M1, M2 and M3 implemented and accepted** (M3: 2026-09-03).
**Next:** **M4 — named Save Points.** Its brief has not been written yet, and
writing it is the current action.
milestones **M1, M2, M3 and M4 implemented and accepted** (M3 and M4:
2026-09-03).
**Next:** **M5 — Genre-Neutral Authoritative Narrative State.** Its brief has not
been written yet, and writing it is the current action.
**Package version:** see `VERSION.md`, which records what each revision changed
and why.
@@ -95,7 +96,7 @@ Two standing qualifications:
10. `BROWSER-UX-SPEC.md`
11. `V1-ACCEPTANCE-TESTS.md`
12. `DECISIONS/` — all of them; they are short.
13. `reports/M3-IMPLEMENTATION-REPORT.md`, for what the last milestone actually
13. `reports/M4-IMPLEMENTATION-REPORT.md`, for what the last milestone actually
left behind. Nothing in `planning/archive/` unless sent there.
## Architectural decisions
@@ -126,13 +127,12 @@ work until Phase 0 closes — which Phase 0 satisfied on 2026-09-01. It is in
`reports/` holds the report for the milestone most recently completed, because
that is the one the next milestone's planning has to consult:
- `reports/M3-IMPLEMENTATION-REPORT.md` — M3's review **and** its primary
evidence record; no separate M3 baseline report was produced. M4 needs its
§W (closeout) and §M/§W.4 (the open browser smoke test).
- `reports/M4-IMPLEMENTATION-REPORT.md` — M4's review and its evidence record.
Completed earlier milestones are in `archive/milestone-reports/`. When M4's
report lands, M3's moves there too: a milestone report is useful during the
immediate next milestone and historical afterwards.
Completed earlier milestones are in `archive/milestone-reports/`, which M3's
report joined when M4's landed: a milestone report is useful during the
immediate next milestone and historical afterwards. M3's is now at
`archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`, unedited.
## The decision this package rests on
@@ -230,13 +230,13 @@ Milestone M2 COMPLETE (2026-09-02)
|
v
Milestone M3 COMPLETE (2026-09-03)
non-destructive undo/redo, reports/M3-IMPLEMENTATION-REPORT.md
non-destructive undo/redo, archive/milestone-reports/M3-*.md
active-head export and ADR 012
|
v
Milestone M4 NEXT — brief not yet prepared
named Save Points
|
Milestone M4 COMPLETE (2026-09-03)
named Save Points reports/M4-IMPLEMENTATION-REPORT.md
| browser verification: PASS (M3 + M4)
v
M5-M11, one at a time see BUILD-MILESTONES.md
```
@@ -245,15 +245,17 @@ M5-M11, one at a time see BUILD-MILESTONES.md
**One milestone at a time. Do not begin a milestone before its brief exists.**
**No M4 brief has been prepared.** Writing one is the current action, informed
by the post-M3 corrections below, by the note `BUILD-MILESTONES.md` attaches to
M4, and by **ADR 012**, which records the head-movement mechanism M4 must reuse
rather than reimplement.
**No M5 brief has been prepared.** Writing one is the current action, informed by
the M4 report's §U readiness assessment and by the note `BUILD-MILESTONES.md`
attaches to M5 — in particular that M4 added 55 tests using the inherited
world-state values as deterministic instrumentation, which M5 must **move rather
than delete**.
One M3 condition remains open and does not block M4: the required **browser
smoke test has not been performed**, because no session in which M3 was
implemented or reviewed had a browser available. See
`reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4.
**No conditions remain open on M1-M4.** The browser smoke condition that M3 and
M4 both carried was satisfied at M4 closeout: a real Firefox exercised the
rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's
§M.2 and the M4 report's §M record the condition as it stood; the M4 report's §W
records it closed.
## What each milestone closeout corrected
+90 -25
View File
@@ -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
@@ -467,6 +499,39 @@ They should not be removed by:
- branch divergence,
- ordinary history cleanup.
### 19.1 A checkpoint protects the history it names
Added at M4 closeout, from implementation evidence.
§19's durability rule is only meaningful if something enforces it against the
operations that delete history. Deleting a branch deletes that branch and
everything forked from it, so a checkpoint naming a position anywhere in that
subtree would go with it — and go silently, because the story is what the user
asked to delete and the named moments are not mentioned in the request.
Therefore:
- **A branch cannot be deleted while a checkpoint names a position on it, or on
any branch forked from it.** The deletion is refused, not amended: nothing is
half-deleted, and no checkpoint is quietly relocated or dropped.
- **The refusal names the checkpoints standing in the way**, so the user knows
what to act on rather than being told only that something is in the way.
- **The user resolves it by deleting the checkpoint explicitly**, which is
§19's "until explicitly deleted" being honoured rather than worked around.
Deleting a checkpoint still deletes no story (§25), so the recovery costs the
user nothing they wanted to keep.
- **The scope is the subtree**, not the named branch alone.
This is the same rule §28 already anticipates for a future cleanup feature —
*retain paths referenced by checkpoints* — applied to the one deletion path that
exists today. A cleanup or discarded-history feature added later must honour it
too, and must not acquire a way to delete a checkpoint as a side effect of
deleting history.
Campaign deletion is not an exception to this and needs no rule: deleting a
campaign deletes everything in it, checkpoints included, and that is what the
user asked for.
## 20. Checkpoint Restore
Restoring a checkpoint:
+75
View File
@@ -413,6 +413,57 @@ than act silently, because retained history must not be made to disagree with
itself in a way the user cannot see. See `STORY-BRANCH-SEMANTICS.md` §10 and
§14A.
### 8.8 Save Points, as implemented in M4
M4 added durable named Save Points and built nothing in §8 that was not already
there. This records what the milestone establishes as fact.
**A Save Point is a name and a coordinate.** The stored row holds the name, an
optional note, and `(branch, depth)` — the same pair §8.7 calls the head. It
holds no transcript, no state, no summary, no memory, and no branch contents.
`DATA-MODEL.md` §8 describes the pointer as naming a turn; the coordinate is
that turn's address, and `DATA-MODEL.md` §8's implementation note records why
this project uses the address rather than a row id: one coordinate can hold
several attempts at a turn, and a retry replaces the live one. "Turn 42 of this
line" survives a retry; a row id would pin a take the story no longer tells.
**Restore is head movement, and nothing else.** It resolves the coordinate,
refuses it if it no longer names a live turn, and then moves the head — the
depth through §8.7's single move operation, unchanged. The transcript, the
assembled context, the state and memory eligibility all arrive together because
they already read through the one capped lineage. There is no second restore
path, no state reconstruction, no memory pruning and no separate redo stack:
D13 is satisfied by the mechanism rather than by code written to satisfy it.
**A Save Point may name a position on a line the story has left.** Save Points
survive divergence, so this is reachable in ordinary use, and the depth half of
the head cannot reach a branch the current path does not contain. Restore
therefore moves the branch half as well when, and only when, the coordinate is
not on the path being read — the same single assignment a branch switch makes.
The distinction matters in the other direction too: a Save Point in a shared
prefix must *not* drag the reader onto the ancestor, because which continuation
follows that turn is exactly what the reader has already chosen.
**Restore never forks.** Moving the head is not a decision to abandon anything.
The first write below the restored head forks, through §8.7's existing check,
and the displaced future stays retained — so Redo still walks the original
continuation until the user writes something different, and stops offering it
once they have.
**Nothing removes a Save Point but the user.** There is no cleanup pass, and none
is wanted: a Save Point pointing behind the head, or into a line the story left,
is doing its job.
That rule is enforced against the one operation that could break it. Deleting a
branch deletes everything forked from it, so a Save Point naming a position in
that subtree would go too — silently, since the story is what the user asked to
delete. **The deletion is therefore refused while any Save Point names that
subtree**, and the refusal names them. The user deletes the Save Point
explicitly, which deletes no story, and then the branch. `STORY-BRANCH-SEMANTICS.md`
§19.1 states the rule; §28 already required a future cleanup feature to retain
paths a checkpoint references, and this is that requirement applied to the
deletion path that exists today.
## 9. Export / Import and Head Position
AI-DnD's current export carries branch information but reconstructs the imported head at the branch tip.
@@ -460,6 +511,30 @@ Every row of an abandoned line is exported either way, so without that metadata
restored campaign could not distinguish abandoned history from active history —
which is precisely what a later cleanup or recovery feature has to select on.
### 9.2 Save Points in the bundle, as implemented in M4
Save Points are exported and imported with the campaign, which is `I04`. They
fall on the "chosen" side of §9.1's rule without argument: a position someone
named is not recoverable from the rows, since nothing about a turn records that
a player once bookmarked it.
No format version bump. A bundle written before M4 has no `checkpoints` key and
imports with none, which is what such a campaign had — the same unambiguous
absence §9.1 relies on for the head depth, and the same treatment the persona
block and the branch disposition received.
The head and the Save Points are independent, deliberately. An import opens the
campaign where `headDepth` says, never at a Save Point merely because the file
carries one: the bundle records where the story was being read and, separately,
which positions were named, and choosing between them is the user's to make
after the file is open.
A Save Point whose coordinate names no turn in the file is dropped rather than
refusing the import — the opposite of the head depth's treatment, and for a
stated reason. A misplaced head affects every read in the file; a bookmark
pointing outside the story affects only itself, and rejecting a whole campaign
to protect one bookmark would lose the story to save the pointer.
## 10. Authoritative Narrative State
### 10.1 Do not retain the RPG state protocol as the product model
+138 -19
View File
@@ -1,8 +1,28 @@
# Adventure Storyteller — V1 Acceptance Tests
**Status:** v1.2 planning/release contract — updated after Phase 0B, after M2 for
the security contract (H10 strengthened, H12 added), and after M3 for history
ownership and results (D03, D10, I07, L01)
**Status:** v1.3 planning/release contract — updated after Phase 0B, after M2 for
the security contract (H10 strengthened, H12 added), after M3 for history
ownership and results (D03, D10, I07, L01), and after M4 for Save Point results
(D11-D14, I04, L03, E-series) and the browser condition below
> **Browser-level verification (M4 closeout, 2026-09-03).** The browser smoke
> condition that M3 and M4 both carried is **satisfied**. A real Firefox 154.0.1,
> driven through geckodriver over the W3C WebDriver protocol, exercised the
> rendered DOM: M3's Undo/Redo enable states, transcript movement, Retry and the
> take pager, divergence and the loss of Redo; and M4's full Save Point
> lifecycle including both confirmations and the branch-delete warning. 44/44
> checks passed with no console errors, on two independent runs. No pass
> condition anywhere in this document was changed to achieve it. See
> `reports/M4-IMPLEMENTATION-REPORT.md` §W.
>
> **Three kinds of evidence are recorded separately below, and are not
> interchangeable.** *Automated* means a test in the repository's suite, which
> runs on every future change. *Live runtime* means a real server exercised over
> HTTP — stronger than a unit test about process boundaries, weaker than a
> browser about anything a user sees. *Browser* means the rendered DOM driven by
> a real browser, which is the only evidence that a control is visible, enabled
> and wired. Where a result cites more than one, the strongest is named last.
**Purpose:** Define black-box acceptance tests for finalist evaluation during Phase 0B and for the eventual v1 release.
## 1. Test Philosophy
@@ -497,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
@@ -701,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.
---
@@ -737,6 +798,14 @@ Before entering the abbey
### Pass
Checkpoint persists across application restart.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* `backend/tests/test_process_restart.py` starts the application as a
subprocess, writes the campaign, **terminates the process**, and starts a second
process against the same database — the Save Point, its name and its
`(branch, depth)` coordinate all survive.
*Browser:* the Save Point is still listed after a full page reload
(`reports/M4-IMPLEMENTATION-REPORT.md` §W.7 section E).
---
## D12 — Restore Checkpoint
@@ -751,6 +820,11 @@ Checkpoint persists across application restart.
### Pass
Transcript/state return to checkpoint position.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* `test_d12_restore_returns_the_transcript_and_the_state`.
*Browser:* the visible transcript and the state both move back, and the view
refreshes without a manual reload (§W.7 section F).
---
## D13 — Restore Does Not Delete Later History
@@ -760,6 +834,13 @@ Transcript/state return to checkpoint position.
### Pass
Later story is retained as abandoned/disposable history.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* measured on **row identity**, not on counts — the set of action row
ids before a restore equals the set after it. Ordinary Redo still walks the retained
continuation until a divergent write, and after that write the displaced rows are
still present while Redo reports nothing ahead. Four `test_d13_*` tests, and
confirmed in the browser with a database check behind it.
---
## D14 — Delete Checkpoint
@@ -773,10 +854,31 @@ Delete named checkpoint.
- checkpoint pointer disappears,
- referenced story turn/history remains intact.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* the pointer row goes; the referenced turn, the later history and the
active head are all unchanged (`test_d14_delete_removes_the_pointer_and_no_story`).
*Browser:* the confirmation states that deleting the Save Point does not delete
the story, and the story remains afterwards (§W.7 section I).
A Save Point is also the **only** thing that can remove itself: deleting a branch
whose history a Save Point names is refused rather than cascading
(`STORY-BRANCH-SEMANTICS.md` §19.1), verified automatically and in the browser.
---
# E. Branch and Lineage Safety
> **M4 result (2026-09-03).** E01 and E04 were re-exercised through a Save Point
> restore rather than only through Undo, and pass: a memory derived past a
> restored head stops being retrievable and becomes eligible again on Redo,
> without being deleted or re-embedded; after restore-plus-divergence the old
> future's memory stays ineligible even as the new line grows past its depth; and
> the transcript after a restore holds only the active lineage while the
> displaced rows remain in the tree. **E03** (summary lineage over a long story)
> remains **NOT PERFORMED** — it needs a long-run campaign and is owned by
> M6/M11, unchanged from M3.
## E01 — Abandoned Future Cannot Affect Active State
**Priority:** REQUIRED FOR V1
@@ -1353,6 +1455,15 @@ Export preserves retained alternate/disposable history needed for recovery, unle
### Pass
Named checkpoints survive export/import.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated and live runtime.* A real round trip with three Save Points across two
branches: names, notes and
coordinates survive, branch references are remapped to the imported rows
(1→3, 2→4), and each restores to a distinct position and state in the new
campaign. Importing Save Points does **not** move the active head — the head
still comes from the bundle's `headDepth`. Bundles written before M4 carry no
`checkpoints` key, import cleanly, and create none.
---
## I05 — Knowledge Provenance Export
@@ -1581,6 +1692,14 @@ State at each position matches original accepted state.
### Pass
Correct historical state is reconstructed.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated, across a genuine OS process boundary:* the state at the Save Point
was recorded before the first process exited, and a second process restored
exactly that value after the campaign had been advanced past it
(`backend/tests/test_process_restart.py`). This replaced a same-process
`TestClient` restart, which could not distinguish durable state from a live
object.
---
## L04 — Derived Data Can Be Rebuilt
+124 -3
View File
@@ -1,8 +1,127 @@
# Planning Package Version
- **Package:** Adventure Storyteller Planning Package v2.4
- **Package:** Adventure Storyteller Planning Package v2.6
- **Revision date:** 2026-09-03
- **Status:** Phase 0 complete; architecture selected; **Milestones M1, M2 and M3 implemented and accepted**; M4 is next to brief.
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M4 implemented and accepted**; M5 is next to brief.
## v2.6 — M4 Closeout (2026-09-03)
M4 is **accepted**. Its review returned *PASS WITH CORRECTIVE WORK REQUIRED*; the
corrective work is done, and the browser condition that M3 and M4 both carried is
closed.
**The three review findings, fixed:**
- **B-1 — the Save Point list was an N+1.** It resolved each Save Point with its
own query and loaded whole `Action` rows, narration included, to answer "does a
row exist here". It is now one bulk two-column coordinate query plus one
lineage computation: **53 SELECTs for 25 Save Points became 5**, and the count
no longer grows with the list. Guarded by three tests, including one proving
the coordinate is matched as a *pair* — an `IN`-list version would report a
Save Point resolved because another branch has a live row at the same depth.
- **B-2 — deleting a branch silently deleted its Save Points.** Fixed as a
**behaviour** defect rather than a missing warning, because
`STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint remains until
explicitly deleted and §28 already required future cleanup to retain
checkpoint-referenced paths. **A branch a Save Point names can no longer be
deleted.** The request is refused with the offending Save Points named; the
user deletes them explicitly, which deletes no story, and the branch then
goes. Both delete controls disable and explain. Recorded as a new
**§19.1**.
- **B-3 — the D11/L03 automation never left one process.** A new module spawns
real server processes, kills the first, and reads the campaign back with the
second.
**Also fixed (review §S C-5):** creating a Save Point now takes the campaign's
turn lock, so "save where I am" cannot read a head a turn in flight is about to
move. Rename and Delete deliberately do not take it, and a test pins that
decision.
**Real-browser verification — the first in this project.** A Firefox 154.0.1
driven through geckodriver over the W3C WebDriver protocol exercised the rendered
DOM for **both** milestones: **44/44 checks passed**, no console errors. It
covered M3's Undo/Redo enable states, transcript movement, Retry and the take
pager, and divergence retiring Redo; and M4's whole Save Point lifecycle
including both confirmations and the new branch-delete warning. **The outstanding
M3 browser condition is therefore closed as well.** No dependency was added to
the repository: the WebDriver client for the run was written against stdlib HTTP.
**Also corrected, found while fixing B-2:** `models.py`, `TECHNICAL-DESIGN.md`
§8.8 and `DATA-MODEL.md` §8 all described the cascade as the durability rule.
They now describe the refusal, and record that `checkpoints.branch_id`'s cascade
survives as referential integrity that the application no longer reaches.
**Documents corrected by this closeout:**
- `V1-ACCEPTANCE-TESTS.md` records results for **D11-D14, I04, L03** and the
E-series, and states that the browser-level condition is satisfied. **No pass
condition was weakened** — and D11/L03 now note that the automation crosses a
genuine OS process boundary, which is the standard later milestones should
meet.
- `DATA-MODEL.md` §8 records the coordinate as implemented, with the retry
measurement that settles coordinate-versus-turn-id, and three decisions that
were previously implicit: names are not unique, several Save Points may name
one position, and the list is newest-created first.
- `STORY-BRANCH-SEMANTICS.md` gains **§19.1** — a checkpoint protects the
history it names. This is the only behavioural specification change in the
closeout, and it strengthens §19 rather than weakening anything.
- `BROWSER-UX-SPEC.md` §25 rules for the implemented vocabulary: **Moment N**,
not *Turn N*, because the branch panel and tree overlay already count in
moments. Vocabulary only; no behaviour changes.
- `BUILD-MILESTONES.md` marks **M4 COMPLETE**, records the fixes and the browser
result, and warns M5 that the instrumentation to move is now **55 tests**.
- `README.md` records M1-M4 accepted and M5 as next to brief.
- `PROJECT-SOURCES.md` and `project-sources.txt` point at the current report;
`project-sources.txt` still named the archived M3 report and was corrected.
**Report rotation** happened in the reporting pass that preceded this closeout:
`M3-IMPLEMENTATION-REPORT.md` moved to `archive/milestone-reports/` as a pure
rename, and `reports/` now holds M4's report, whose **§W** is this closeout's
evidence record.
**No new ADR.** ADR 012 already decides the architecture, and the corrective work
forced no new architectural decision. `SPECIFICATION.md`,
`STORY-BRANCH-SEMANTICS.md`, `SECURITY-THREAT-MODEL.md`, `CONTEXT-AND-MEMORY.md`,
`IMPORTED-KNOWLEDGE-DESIGN.md` and ADRs 003, 005 and 012 are unchanged.
**M5 readiness:** ready. Save Points store no state and no checkpoint code reads
any, so M5 can change what a snapshot contains without touching what a Save Point
is — provided it keeps state recoverable at a position without replay
(`TECHNICAL-DESIGN.md` §10.4).
## v2.5 — M4 Implementation (2026-09-03)
M4 added durable named Save Points. This revision records only what the
implementation established as fact; **no product requirement changed**, and the
milestone is **not** marked accepted — its review has not been written.
- `TECHNICAL-DESIGN.md` gains **§8.8** and **§9.2**: the Save Point as a name
plus a coordinate holding no story, restore as head movement with a bounds
check, the rule that the branch half of the head moves only when the
coordinate is off the path being read, restore never forking, and the bundle
carrying Save Points independently of the head.
- `DATA-MODEL.md` **§8** records the pointer as implemented — `(branch, depth)`
rather than a turn id, with the reason: one coordinate holds every attempt at
a turn and exactly one is live, so a coordinate follows a retry where a row id
would pin a superseded take. **§29** records the checkpoints in the export.
- `BUILD-MILESTONES.md` **M4** gains a status block: what shipped, the one
architectural decision the milestone had to make and why it needed no new ADR,
the test count, and the outstanding browser condition.
- `README.md` describes Save Points as a user-facing capability.
**No ADR was created.** ADR 012 already decides the architecture M4 needed —
restore reuses active-head movement — and a table is not a decision. The one
question ADR 012 does not answer, whether restore moves the branch half of the
head, is that same mechanism applied to a coordinate ADR 012 already defines;
`TECHNICAL-DESIGN.md` §8.8 records the answer rather than a new ADR asserting it.
`SPECIFICATION.md`, `SECURITY-THREAT-MODEL.md`, `STORY-BRANCH-SEMANTICS.md` and
`V1-ACCEPTANCE-TESTS.md` are unchanged. M4 altered no product requirement, added
no outbound path, and implemented the checkpoint semantics
`STORY-BRANCH-SEMANTICS.md` §18-25 already specified rather than amending them.
**The browser smoke test remains unperformed, now for both M3 and M4.** No
session has had a usable browser. See `BUILD-MILESTONES.md` M3 and M4.
## v2.4 — Documentation Consolidation (2026-09-03)
@@ -43,7 +162,7 @@ tell authoritative material from evidence at a glance.
## v2.3 — Post-M3 Closeout (2026-09-03)
M3 replaced destructive Undo with a stored active head. Its review is
`reports/M3-IMPLEMENTATION-REPORT.md`, which is also M3's primary evidence
`archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`, which is also M3's primary evidence
record — no separate baseline report was produced — and whose §W records this
closeout.
@@ -153,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.
+5 -1
View File
@@ -41,7 +41,11 @@ Git history.
### `milestone-reports/` — completed milestone evidence
`M1-BASELINE-REPORT.md`, `M1-IMPLEMENTATION-REPORT.md`,
`M2-BASELINE-REPORT.md`, `M2-IMPLEMENTATION-REPORT.md`.
`M2-BASELINE-REPORT.md`, `M2-IMPLEMENTATION-REPORT.md`,
`M3-IMPLEMENTATION-REPORT.md`.
M3's report is both its review and its primary evidence record; no separate M3
baseline report was produced. It arrived here when M4's report landed.
Every architectural conclusion these reports reached has already been applied to
the active planning documents and the ADRs — see `planning/VERSION.md`, which
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -26,4 +26,4 @@ planning/DECISIONS/009-ai-dnd-production-base.md
planning/DECISIONS/010-explicit-typed-narrative-state-events.md
planning/DECISIONS/011-local-inference-endpoint-policy.md
planning/DECISIONS/012-active-head-non-destructive-history.md
planning/reports/M3-IMPLEMENTATION-REPORT.md
planning/reports/M4-IMPLEMENTATION-REPORT.md
@@ -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**