Files
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

300 lines
12 KiB
Python

"""Tests for the changes the engine refuses, and for naming a milestone.
A refused change used to leave no trace a player could see. The turn summary
read `world_delta["applied"]` alone, so a value the model pushed past its
ceiling came back with a delta of 0 and rendered as an ordinary chip. The
engine had recorded the refusal and nothing showed it.
The milestone half is the same shape. `apply_delta` matches a milestone by its
schema id, and the context named goals by description only, so the model had no
way to learn the id it was being asked to send.
python -m pytest tests/test_change_visibility.py -v
"""
import json
import pathlib
from app import models
from app import worldstate as w
from app.routers.adventures import world_delta_of
SEED = pathlib.Path(__file__).resolve().parents[1] / "app" / "seed_data"
SCHEMA = {
"world": {"day": {"type": "counter", "min": 1, "initial": 1}},
"player": {
"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30},
# Starts at its own ceiling and only ever falls, which is the shape that
# turns a wrong-signed delta into a change of nothing.
"arrows": {"min": 0, "max": 6, "initial": 6, "max_delta_per_turn": 1},
},
"npcs": {"gwen": {"name": "Gwen", "stats": {"trust": {"min": -100, "max": 100, "initial": 0}}}},
"flags": {"has_key": {"desc": "Holds the key", "initial": False}},
"milestones": {
"rescue_gwen": {"desc": "Rescue Gwen"},
"escape_keep": {"desc": "Escape the keep"},
},
}
def action(delta, index=1):
"""Returns an unsaved Action carrying the report `apply_delta` produced.
The column is filled through `world_delta_of`, the same function the turn
endpoint uses, rather than by assembling the dict here. An earlier version
of this helper built the shape by hand with every report list present. That
hid a live bug: `world_delta_of` stored `applied` alone, so no refusal ever
reached a chip or the next prompt while all of these tests passed.
"""
ws, report = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, delta, index)
snapshot = {"world_state": {"delta": delta, "report": report, "state": ws}}
return models.Action(world_delta=world_delta_of(snapshot))
def test_world_delta_of_keeps_every_report_list():
"""The stored column must carry the refusals, not just the successes.
`Action.world_changes` marks a clamped chip from `clamped` and builds its
refusal chips from `rejected`, and `worldstate.refusals` reads both. Dropping
either list leaves every consumer unable to tell a refused change from one
that worked.
"""
_, report = w.apply_delta(
w.instantiate(SCHEMA), SCHEMA,
{"player.arrows": 3, "player.nonesuch": 1, "player.hp": -5}, 1,
)
stored = world_delta_of({"world_state": {"delta": {}, "report": report}})
assert [e["path"] for e in stored["applied"]] == ["player.arrows", "player.hp"]
assert [e["path"] for e in stored["clamped"]] == ["player.arrows"]
assert [e["path"] for e in stored["rejected"]] == ["player.nonesuch"]
def test_world_delta_of_survives_a_snapshot_with_no_world_state():
assert world_delta_of({"story": "s"}) is None
assert world_delta_of(None) is None
# --------------------------------------------------------------------------- #
# The goals line names the id the AI has to send
# --------------------------------------------------------------------------- #
def test_goals_line_names_the_milestone_id():
line = w.render_state_section(w.instantiate(SCHEMA), SCHEMA, {})
assert "rescue_gwen — Rescue Gwen" in line
assert "escape_keep — Escape the keep" in line
# The path is spelled out, because the id alone does not say how to send it.
assert "milestones.<id>" in line
def test_achieved_line_names_the_id_too():
ws, _ = w.apply_delta(w.instantiate(SCHEMA), SCHEMA, {"milestones.rescue_gwen": True}, 1)
line = w.render_state_section(ws, SCHEMA, {})
assert "Achieved: rescue_gwen — Rescue Gwen." in line
# A reached milestone leaves the goals list.
assert "rescue_gwen — Rescue Gwen;" not in line
def test_every_milestone_in_the_demo_is_named_to_the_model():
"""The demo that exposed this must not lose the ids again."""
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
line = w.render_state_section(w.instantiate(schema), schema, {})
for mid in schema["milestones"]:
assert mid in line
# --------------------------------------------------------------------------- #
# A refused change reaches the turn summary
# --------------------------------------------------------------------------- #
def test_a_clamp_that_changes_nothing_is_still_reported():
"""The Pokémon bug: a positive delta on a stat already at its ceiling.
`arrows` sits at 6 of a maximum 6, so `+2` caps to `+1`, reaches 7, and
clamps back to 6. The value never moves, and the summary has to say so.
"""
chips = action({"player.arrows": 2}).world_changes
arrows = [c for c in chips if c["label"] == "arrows"]
assert len(arrows) == 1
assert arrows[0]["delta"] == 0
assert arrows[0]["clamped"] is True
def test_a_clamp_that_still_moves_the_value_is_marked():
chips = action({"player.hp": -80}).world_changes
hp = [c for c in chips if c["label"] == "hp"][0]
assert hp["delta"] == -30 # max_delta_per_turn
assert hp["clamped"] is True
def test_an_accepted_change_is_not_marked():
chips = action({"player.hp": -10}).world_changes
hp = [c for c in chips if c["label"] == "hp"][0]
assert hp["delta"] == -10
assert hp["clamped"] is False
def test_a_refusal_becomes_its_own_entry_carrying_the_reason():
chips = action({"world.day": -1, "player.stamina": 5}).world_changes
refused = {c["label"]: c["reason"] for c in chips if c["kind"] == "rejected"}
assert refused == {"day": "counter can't decrease", "stamina": "unknown stat"}
def test_a_refused_npc_stat_keeps_the_npc_in_its_label():
chips = action({"npc.gwen.bogus": 5}).world_changes
refused = [c for c in chips if c["kind"] == "rejected"][0]
assert refused["label"] == "gwen bogus"
def test_accepted_and_refused_changes_appear_together():
chips = action({"player.hp": -10, "world.day": -1}).world_changes
kinds = [c["kind"] for c in chips]
assert "stat" in kinds and "rejected" in kinds
def test_a_turn_that_changed_nothing_still_has_no_chips():
assert models.Action(world_delta=None).world_changes == []
def test_flags_and_milestones_are_unchanged_by_the_new_fields():
chips = action({"flags.has_key": True, "milestones.rescue_gwen": True}).world_changes
assert {"kind": "flag", "label": "has_key", "on": True} in chips
assert {"kind": "milestone", "label": "rescue_gwen"} in chips
# --------------------------------------------------------------------------- #
# The demo stat counts up, so a wrong sign is refused rather than absorbed
# --------------------------------------------------------------------------- #
def test_milos_faint_counter_counts_up_from_zero():
"""A stat that starts at its ceiling cannot report a wrong sign.
`pokemon_left` began at 3 of a maximum 3, so the model sending the count it
had left rather than a delta clamped to no change at all. Counting the
faints up from 0 puts the wrong direction on the counter rule, which
refuses it out loud.
"""
schema = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))["stat_schema"]
stat = schema["npcs"]["milo"]["stats"]["pokemon_fainted"]
assert stat["initial"] == 0 and stat["type"] == "counter"
ws = w.instantiate(schema)
after, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": 1}, 1)
assert after["npc"]["milo"]["pokemon_fainted"] == 1
_, report = w.apply_delta(ws, schema, {"npc.milo.pokemon_fainted": -1}, 1)
assert len(report["rejected"]) == 1
refused = report["rejected"][0]
assert refused["path"] == "npc.milo.pokemon_fainted"
assert refused["reason"] == "counter can't decrease"
def test_the_demo_no_longer_mentions_the_old_stat():
"""The instructions name the stat, so a rename has to reach them too."""
raw = (SEED / "05-league-championship.json").read_text(encoding="utf-8")
assert "pokemon_left" not in raw
assert "pokemon_fainted" in json.loads(raw)["ai_instructions"]
def test_the_demo_asks_for_the_turn_counter():
"""`world.turn` sat at 0 for a whole playtest: nothing told the model to
move it. The schema defining a stat is not an instruction to update it."""
d = json.loads((SEED / "05-league-championship.json").read_text(encoding="utf-8"))
assert "world.turn" in d["stat_schema"]["world"] or "turn" in d["stat_schema"]["world"]
assert "world.turn" in d["ai_instructions"]
# --------------------------------------------------------------------------- #
# What the model is told about its own refused changes
# --------------------------------------------------------------------------- #
def test_history_replays_prose_without_the_protocol_block():
"""M5 corrective pass (review Finding 4): replayed history is prose only.
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 = 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 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_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 = 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 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():
note = w.render_refusals(action({"milestones.bogus": True}).world_delta)
assert "There is no milestone `bogus`" in note
# The correction has to name what it could have sent instead.
assert "`rescue_gwen`" in note and "`escape_keep`" in note
def test_a_wrong_sign_on_a_counter_is_explained():
note = w.render_refusals(action({"world.day": -1}).world_delta)
assert "only counts up" in note
def test_a_clamp_that_moved_nothing_quotes_the_limit():
note = w.render_refusals(action({"player.arrows": 2}).world_delta)
assert "did not move" in note
assert "maximum of 6" in note and "it runs from 0 to 6" in note
def test_a_clamp_that_still_moved_the_value_says_nothing():
"""Reporting a trimmed change invites the model to send the remainder next
turn, which is the swing `max_delta_per_turn` exists to prevent."""
assert w.render_refusals(action({"player.hp": -80}).world_delta) == ""
def test_a_clean_turn_adds_no_note():
assert w.render_refusals(action({"player.hp": -10}).world_delta) == ""
assert w.render_refusals(None) == ""