Files
interactive-story/backend/tests/test_narrative_state.py
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

1561 lines
66 KiB
Python

"""M5: the genre-neutral authoritative narrative state.
This file is the acceptance contract for the milestone that replaced AI-DnD's
relative-delta RPG world state. Its subject is one claim:
The application decides what is true, from explicit typed proposals it
validated, and it can say why at any position in the story.
The tests are grouped by the question they answer, and named for the acceptance
items they discharge — C01-C04, C06, H05, L01-L02, J01-J03 in
`planning/V1-ACCEPTANCE-TESTS.md`.
Two things this file deliberately does **not** do. It does not test the model:
every proposal here is scripted, because what is under test is what the
application does with a proposal, not whether a given model produces a good one
— that is `test_narrative_realistic.py`, which needs a real model and says so.
And it does not re-test M3/M4 history; the history suites do that, now
instrumented through this state model.
python -m pytest tests/test_narrative_state.py -v
"""
import json
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, head, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.narrative import apply as napply
from app.narrative import events as nevents
from app.narrative import extract, model, store, validate
from app.routers import adventures
from fakes import ScriptedProvider, state_block
# --------------------------------------------------------------- fixtures
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="state@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
adv = models.Adventure(user_id=user.id, title="The Crooked Lantern")
setup.add(adv)
setup.flush()
setup.add(models.Action(adventure_id=adv.id, type="start", text="The road forks."))
setup.commit()
adv_id, user_id = adv.id, user.id
setup.close()
ScriptedProvider.replies = ["Nothing happens." + "\n" + state_block([])]
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(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()
adventures.turns._active_turns.clear()
Base.metadata.drop_all(bind=engine)
def _play(client, text="look around", reply=None, events=None):
if events is not None:
ScriptedProvider.replies = [f"{reply or 'It happens.'}\n{state_block(events)}"]
elif reply is not None:
ScriptedProvider.replies = [reply]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text
return r
def _state(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/state")
assert r.status_code == 200, r.text
return r.json()
def _document(client) -> dict:
return _state(client)["document"]
def _events(client) -> list:
r = client.get(f"/api/adventures/{client.adv_id}/state/events")
assert r.status_code == 200, r.text
return r.json()
def _correct(client, events, note=""):
return client.post(
f"/api/adventures/{client.adv_id}/state/corrections",
json={"events": events, "note": note},
)
def _ai_ids(client) -> set:
"""The ids of every accepted narration, whatever the head is doing."""
db = SessionLocal()
try:
return {
a.id for a in db.query(models.Action).filter_by(
adventure_id=client.adv_id, type="ai")
}
finally:
db.close()
def _proposals(adv_id):
db = SessionLocal()
try:
return (
db.query(models.StateProposal)
.filter_by(adventure_id=adv_id)
.order_by(models.StateProposal.id)
.all()
)
finally:
db.close()
def _canon(adv_id, canon):
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
adv.campaign_canon = canon
db.commit()
finally:
db.close()
#: The fantasy fixture the acceptance tests are written against — the Continuity
#: Test's cast, as typed events.
FANTASY = [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
"name": "the silver key"},
{"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": "old-abbey"},
]
# ====================================================== the event vocabulary
def test_every_allowed_event_applies_and_nothing_else_is_dispatched(client):
"""Each event in the vocabulary does something, and the vocabulary is the
whole of what can be done. A spec with no applier would accept a proposal
and silently change nothing — the one failure mode that looks like success.
"""
_play(client, "set the scene", events=FANTASY)
_play(client, "more", events=[
{"type": "set_entity_status", "entity": "mara", "status": "injured"},
{"type": "set_entity_attribute", "entity": "mara", "attribute": "resolve",
"value": 3},
{"type": "set_entity_conditions", "entity": "aldric",
"conditions": ["exhausted"]},
{"type": "add_fact", "subject": "mara", "predicate": "knows",
"object": "silver-key", "fact_id": "mara-knows-key"},
{"type": "add_relationship", "source": "aldric", "target": "mara",
"relationship": "trusts"},
{"type": "open_story_thread", "thread": "find-edrin",
"title": "Find out what happened to Edrin"},
{"type": "set_scene", "summary": "Rain on the abbey steps",
"location": "old-abbey"},
])
document = _document(client)
assert model.owner_of(document, "silver-key") == "aldric"
assert document["entities"]["aldric"]["location"] == "old-abbey"
assert document["entities"]["mara"]["status"] == "injured"
assert document["entities"]["mara"]["attributes"]["resolve"] == 3
assert document["entities"]["aldric"]["conditions"] == ["exhausted"]
assert model.knows(document, "mara", "silver-key")
assert any(r["type"] == "trusts" for r in model.active_relationships(document))
assert "find-edrin" in model.open_threads(document)
assert document["scene"]["location"] == "old-abbey"
# And the closing halves.
_play(client, "later", events=[
{"type": "clear_possession", "item": "silver-key"},
{"type": "invalidate_fact", "fact_id": "mara-knows-key"},
{"type": "end_relationship", "source": "aldric", "target": "mara",
"relationship": "trusts"},
{"type": "resolve_story_thread", "thread": "find-edrin"},
])
document = _document(client)
assert model.owner_of(document, "silver-key") is None
assert not model.knows(document, "mara", "silver-key")
assert model.active_relationships(document) == []
assert model.open_threads(document) == {}
# Withdrawn, not deleted: the record of what the campaign used to believe is
# what makes a correction auditable.
assert any(f["id"] == "mara-knows-key" for f in document["facts"])
def test_the_applier_covers_every_event_in_the_allowlist():
"""A spec with no branch in `apply` would be accepted and do nothing."""
state = model.empty()
state["entities"]["a"] = model.new_entity(name="A")
state["entities"]["b"] = model.new_entity(name="B")
state["facts"].append({"id": "f1", "predicate": "p", "status": "active"})
state["threads"]["t"] = {"title": "T", "status": "open"}
# `clear_possession` needs something to clear, and `end_relationship` an
# active tie: an event that correctly does nothing to an empty document
# would look identical to one with no applier at all.
state["entities"]["d"] = model.new_entity(name="D")
state["possessions"]["d"] = "b"
state["relationships"].append(
{"id": "r1", "source": "a", "target": "b", "type": "trusts",
"status": "active"})
samples = {
"create_entity": {"entity": "c", "name": "C"},
"set_entity_status": {"entity": "a", "status": "gone"},
"set_entity_attribute": {"entity": "a", "attribute": "x", "value": 1},
"set_entity_conditions": {"entity": "a", "conditions": ["hurt"]},
"set_current_location": {"entity": "a", "location": "b"},
"set_possession": {"item": "a", "owner": "b"},
"clear_possession": {"item": "d"},
"add_fact": {"predicate": "knows"},
"invalidate_fact": {"fact_id": "f1"},
"add_relationship": {"source": "a", "target": "b", "relationship": "trusts"},
"end_relationship": {"source": "a", "target": "b", "relationship": "trusts"},
"open_story_thread": {"thread": "t2", "title": "T2"},
"resolve_story_thread": {"thread": "t"},
"set_scene": {"summary": "here"},
}
assert set(samples) == set(nevents.ALLOWED), (
"the allowlist and this test's coverage have drifted apart"
)
for kind, payload in samples.items():
before = json.dumps(state, sort_keys=True)
after = napply.apply_events(state, [{"type": kind, **payload}])
assert json.dumps(after, sort_keys=True) != before, f"{kind} changed nothing"
# ============================================== H05 and the validation layers
def test_h05_execute_shell_is_rejected(client):
"""H05. The allowlist is checked before any field is read, so `command` is
never looked at — there is no branch in this application that could reach
it. The event is refused because it is not in the vocabulary, not because
"shell" is recognised."""
review = validate.review(
{"events": [{"event_type": "execute_shell", "command": "rm -rf /"}]},
model.empty(),
)
assert review.accepted == []
assert review.status == "rejected"
assert review.rejected[0].reason == validate.UNKNOWN_TYPE
# And end to end: a narration carrying it changes nothing and is recorded.
_play(client, "try it", events=[{"event_type": "execute_shell", "command": "id"}])
assert model.is_empty(_document(client))
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "rejected"
@pytest.mark.parametrize("payload, reason", [
({"type": "not_a_real_event", "entity": "a"}, validate.UNKNOWN_TYPE),
({"type": "set_entity_status"}, validate.MISSING_FIELD),
({"type": "set_entity_status", "entity": "ghost", "status": "x"},
validate.UNKNOWN_REFERENCE),
({"type": "set_entity_attribute", "entity": "a", "attribute": "x",
"value": {"nested": "structure"}}, validate.BAD_FIELD_TYPE),
({"type": "set_entity_conditions", "entity": "a", "conditions": "not a list"},
validate.BAD_FIELD_TYPE),
({"type": "create_entity", "entity": "a", "name": "duplicate"},
validate.DUPLICATE_ENTITY),
({"type": "set_possession", "item": "a", "owner": "a"},
validate.SELF_CONTRADICTION),
({"type": "invalidate_fact", "fact_id": "nope"}, validate.UNKNOWN_REFERENCE),
({"type": "resolve_story_thread", "thread": "nope"}, validate.UNKNOWN_REFERENCE),
("not an object", validate.NOT_AN_OBJECT),
])
def test_the_validator_refuses_each_class_of_bad_event(payload, reason):
state = model.empty()
state["entities"]["a"] = model.new_entity(name="A")
review = validate.review({"events": [payload]}, state)
assert review.accepted == []
assert review.rejected[0].reason == reason, review.rejected[0].as_dict()
@pytest.mark.parametrize("raw", [
"Just prose, no block at all.",
"Prose.\n```state\n{not json at all}\n```",
"Prose.\n```state\n[1, 2, 3]\n```",
'Prose.\n```state\n{"events": "not a list"}\n```',
'Prose.\n```state\n{"events": [null, 3, "x"]}\n```',
])
def test_malformed_proposals_never_mutate_state(client, raw):
"""A narration the user watched arrive is worth keeping even when the block
after it is garbage. The turn commits, the prose is stored clean, and the
state is untouched."""
_play(client, "establish", events=FANTASY)
before = _document(client)
_play(client, "then this", reply=raw)
assert _document(client) == before, "a malformed proposal changed the state"
# The prose survived, and carries no protocol.
texts = [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
assert "```state" not in "\n".join(texts)
assert "Prose." in texts[-1] or "Just prose" in texts[-1]
def test_one_bad_event_does_not_discard_the_good_ones(client):
"""Partial acceptance. Losing three correct events because the model
misspelled one entity would lose story the user watched happen."""
_play(client, "establish", events=FANTASY)
_play(client, "mixed", events=[
{"type": "set_entity_status", "entity": "mara", "status": "injured"},
{"type": "set_current_location", "entity": "nobody", "location": "old-abbey"},
{"type": "set_entity_conditions", "entity": "aldric", "conditions": ["cold"]},
])
document = _document(client)
assert document["entities"]["mara"]["status"] == "injured"
assert document["entities"]["aldric"]["conditions"] == ["cold"]
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "partially_accepted"
def test_a_proposal_may_introduce_an_entity_and_then_use_it(client):
"""Referential checks account for what earlier events in the same proposal
created — otherwise a narration could never introduce anyone."""
_play(client, "arrive", events=[
{"type": "create_entity", "entity": "edrin", "entity_type": "character",
"name": "Edrin"},
{"type": "create_entity", "entity": "cellar", "entity_type": "location",
"name": "the cellar"},
{"type": "set_current_location", "entity": "edrin", "location": "cellar"},
])
assert _document(client)["entities"]["edrin"]["location"] == "cellar"
def test_a_rejected_create_does_not_make_a_later_reference_resolve():
"""The running view must track what was *accepted*, not what was proposed."""
state = model.empty()
state["entities"]["mara"] = model.new_entity(name="Mara")
review = validate.review({"events": [
{"type": "create_entity", "entity": "mara", "name": "Duplicate"},
{"type": "create_entity", "entity": "hall", "name": "Hall"},
{"type": "set_current_location", "entity": "mara", "location": "hall"},
]}, state)
kinds = [e["type"] for e in review.accepted]
assert kinds == ["create_entity", "set_current_location"]
assert review.rejected[0].reason == validate.DUPLICATE_ENTITY
def test_a_proposal_cannot_carry_an_unbounded_number_of_events():
review = validate.review(
{"events": [{"type": "add_fact", "predicate": f"p{n}"} for n in range(80)]},
model.empty(),
)
assert len(review.accepted) == validate.MAX_EVENTS
assert review.rejected
# ================================================================ C01 canon
def test_c01_campaign_canon_outranks_the_narration(client):
"""C01, solved generically. The campaign declares the transition it forbids;
nothing in the application knows what resurrection is, and a science-fiction
campaign forbidding something else uses the same field and the same code."""
_play(client, "establish", events=FANTASY)
_play(client, "she falls", events=[
{"type": "set_entity_status", "entity": "mara", "status": "dead"}])
assert _document(client)["entities"]["mara"]["status"] == "dead"
_canon(client.adv_id, {"forbidden_status_changes": [{"from": "dead", "to": "active"}]})
_play(client, "the rite", events=[
{"type": "set_entity_status", "entity": "mara", "status": "active"}])
assert _document(client)["entities"]["mara"]["status"] == "dead", (
"canon did not hold against the narration"
)
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "rejected"
def test_canon_reaches_the_prompt_as_well_as_the_validator(client):
"""C01 is a narration-time constraint too. Refusing the event after the
fact leaves the reader with prose the state contradicts; the model has to be
told the rule."""
_canon(client.adv_id, {"rules": ["The dead do not return."]})
_play(client, "go on", events=[])
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
prompt = json.dumps(action.context_snapshot)
finally:
db.close()
assert "The dead do not return." in prompt
# ======================================================= C02 possession
def test_c02_possession_persists_until_an_event_changes_it(client):
_play(client, "establish", events=FANTASY)
for n in range(3):
_play(client, f"walk {n}", events=[])
assert model.owner_of(_document(client), "silver-key") == "aldric"
_play(client, "hand it over", events=[
{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
assert model.owner_of(_document(client), "silver-key") == "mara"
def test_c02_undo_recovers_the_historically_correct_possession(client):
_play(client, "establish", events=FANTASY)
_play(client, "hand it over", events=[
{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
assert model.owner_of(_document(client), "silver-key") == "mara"
client.post(f"/api/adventures/{client.adv_id}/undo")
assert model.owner_of(_document(client), "silver-key") == "aldric"
client.post(f"/api/adventures/{client.adv_id}/redo")
assert model.owner_of(_document(client), "silver-key") == "mara"
# ================================================== C03 character knowledge
def test_c03_what_the_campaign_knows_is_not_what_a_character_knows(client):
"""C03's distinction, made structural rather than inferred: a fact with no
subject is the campaign's; a fact whose subject is Mara is hers."""
_play(client, "establish", events=FANTASY)
_play(client, "the key's origin", events=[
{"type": "add_fact", "predicate": "was found in",
"subject": "silver-key", "object": "old-abbey", "fact_id": "key-origin"},
])
document = _document(client)
assert not model.knows(document, "mara", "key-origin"), (
"the campaign knowing something must not mean Mara knows it"
)
_play(client, "she is told", events=[
{"type": "add_fact", "subject": "mara", "predicate": "knows",
"object": "key-origin", "fact_id": "mara-learns"}])
assert model.knows(_document(client), "mara", "key-origin")
# ================================================ C04 manual correction
def test_c04_a_manual_correction_becomes_authoritative_and_is_auditable(client):
"""C04, end to end. The user overrules the story; the transcript is
untouched; the correction is marked as theirs and outranks the story."""
_play(client, "establish", events=FANTASY)
_play(client, "she learns", events=[
{"type": "add_fact", "subject": "mara", "predicate": "knows",
"object": "silver-key", "fact_id": "mara-knows-key"}])
assert model.knows(_document(client), "mara", "silver-key")
transcript_before = [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
r = _correct(client, [
{"type": "invalidate_fact", "fact_id": "mara-knows-key",
"reason": "Mara never learned where the silver key was found."}],
note="Mara never learned where the silver key was found.")
assert r.status_code == 201, r.text
# ...the transcript is untouched by the correction itself...
assert [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]] == transcript_before
# ...it is authoritative for what follows...
assert not model.knows(_document(client), "mara", "silver-key")
_play(client, "carry on", events=[])
assert not model.knows(_document(client), "mara", "silver-key")
# ...auditable, and marked as the user's...
events = _events(client)
correction = next(e for e in events if e["source"] == "manual_correction")
assert correction["event_type"] == "invalidate_fact"
assert correction["before"]["status"] == "active"
assert correction["turn"] is not None
# ...and the story that was already written is still there, word for word,
# with the new turn appended rather than replacing anything.
after = [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
assert after[:len(transcript_before)] == transcript_before
def test_c04_a_correction_is_ranked_above_the_story_in_the_prompt(client):
_play(client, "establish", events=FANTASY)
_correct(client, [{"type": "add_fact", "subject": "mara",
"predicate": "never learned about", "object": "silver-key"}])
from app.narrative import render
rendered = render.for_prompt(_document(client))
assert "corrected by the player" in rendered
def test_c04_a_withdrawn_fact_does_not_come_back_through_history(client):
"""M5 review, Finding 4 — the review's own Mara example, as a regression.
The state section dropped the withdrawn fact and the history section handed
it straight back, replayed as an accepted `add_fact` in the protocol's own
words, with nothing in the prompt saying it had been corrected. The next
prompt therefore contradicted the correction it was supposed to carry.
"""
_play(client, "establish", events=FANTASY)
_play(client, "she learns", events=[
{"type": "add_fact", "subject": "mara",
"predicate": "knows where the key was found", "fact_id": "mara-knows"}])
assert "knows where the key was found" in [
f["predicate"] for f in _document(client)["facts"]]
r = _correct(
client,
[{"type": "invalidate_fact", "fact_id": "mara-knows",
"reason": "Mara never learned where the silver key was found."}],
note="Mara never learned where the silver key was found.",
)
assert r.status_code in (200, 201), r.text
_play(client, "ask mara", events=[])
with SessionLocal() as db:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
sections = {s["label"]: s["text"] for s in action.context_snapshot["sections"]}
# The history carries the story, and none of the protocol.
assert "```state" not in sections["history"]
assert "add_fact" not in sections["history"]
assert "mara-knows" not in sections["history"]
# The one place the assertion still appears says it is no longer true.
state = sections["narrative_state"]
assert "No longer true" in state
assert "Mara never learned where the silver key was found." in state
# And it is not standing among the facts that hold.
established = state.split("No longer true")[0]
assert "knows where the key was found" not in established
def test_a_withdrawn_fact_is_named_rather_than_silently_dropped(client):
"""Dropping it silently left the narration that first asserted it as the
only account in the prompt, and prose reads as current truth."""
from app.narrative import render
_play(client, "establish", events=FANTASY)
_play(client, "she learns", events=[
{"type": "add_fact", "subject": "mara", "predicate": "knows the code",
"fact_id": "code"}])
_correct(client, [{"type": "invalidate_fact", "fact_id": "code",
"reason": "She was never told."}])
rendered = render.for_prompt(_document(client))
assert "No longer true — do not treat these as established:" in rendered
assert "knows the code" in rendered.split("No longer true")[1]
assert "She was never told." in rendered
def test_a_correction_goes_through_the_same_validator(client):
"""A user is trusted with authority, not with references that do not
resolve: a typo should be a clear refusal, not a corrupt document."""
_play(client, "establish", events=FANTASY)
r = _correct(client, [{"type": "set_entity_status", "entity": "nobody",
"status": "gone"}])
assert r.status_code == 400
assert "nobody" in r.json()["detail"]
r = _correct(client, [{"type": "execute_shell", "command": "id"}])
assert r.status_code == 400
def test_a_correction_belongs_to_the_position_it_was_made_at(client):
"""Undoing past a correction drops it, and redoing brings it back — the
same rule every other state change follows."""
_play(client, "establish", events=FANTASY)
_play(client, "second turn", events=[])
_correct(client, [{"type": "set_entity_status", "entity": "mara",
"status": "vanished"}])
assert _document(client)["entities"]["mara"]["status"] == "vanished"
client.post(f"/api/adventures/{client.adv_id}/undo")
assert _document(client)["entities"]["mara"]["status"] != "vanished"
client.post(f"/api/adventures/{client.adv_id}/redo")
assert _document(client)["entities"]["mara"]["status"] == "vanished"
# ================================================ C06 narration/state coherence
def test_c06_accepted_state_matches_the_accepted_narration(client):
"""C06 with an unambiguous consequence: the item moves in the prose, and the
state says it moved."""
_play(client, "establish", events=FANTASY)
_play(client, "hand it over",
reply="Aldric presses the silver key into Mara's palm.",
events=[{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
document = _document(client)
assert model.owner_of(document, "silver-key") == "mara"
texts = [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
assert "presses the silver key" in texts[-1]
assert "```state" not in texts[-1], "the protocol reached the reader"
def test_c06_a_contradictory_proposal_is_refused_rather_than_accepted(client):
"""Two events in one proposal that cannot both be true of the same item."""
_play(client, "establish", events=FANTASY)
review = validate.review({"events": [
{"type": "set_possession", "item": "silver-key", "owner": "silver-key"},
]}, _document(client))
assert review.accepted == []
assert review.rejected[0].reason == validate.SELF_CONTRADICTION
# =========================================================== §8 provenance
def test_every_accepted_event_records_what_it_changed_and_why(client):
"""§8's list: what changed, which turn, model or user, and what it was."""
_play(client, "establish", events=FANTASY)
_play(client, "move", events=[
{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
events = _events(client)
move = next(e for e in events if e["event_type"] == "set_possession")
assert move["payload"]["owner"] == "mara"
assert move["before"] == {"owner": "aldric"}, "the previous value was not recorded"
assert move["source"] == "accepted_story"
assert move["action_id"] is not None
assert move["depth"] is not None
def test_a_rejected_proposal_is_recorded_but_is_not_an_event(client):
"""A rejection has to be inspectable — a wrong-looking campaign with no
trail is the failure this record exists to prevent — without becoming
authoritative."""
_play(client, "establish", events=FANTASY)
before = len(_events(client))
_play(client, "impossible", events=[
{"type": "set_current_location", "entity": "ghost", "location": "old-abbey"}])
assert len(_events(client)) == before, "a rejected event became authoritative"
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "rejected"
db = SessionLocal()
try:
detail = db.get(models.StateProposal, proposal.id).detail
finally:
db.close()
assert detail["rejected"][0]["reason"] == validate.UNKNOWN_REFERENCE
def test_an_unparseable_block_keeps_the_raw_output_for_the_audit(client):
"""The one case no structured column could hold."""
_play(client, "garbage", reply="Prose.\n```state\n{oh no\n```")
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "unparseable"
assert "oh no" in proposal.raw_output
# ================================================== L01 atomic turn commit
def test_l01_a_failed_state_commit_accepts_no_narration(client, monkeypatch):
"""L01. There must be no window in which narration is accepted while its
state is half-written, so the whole turn is one transaction."""
_play(client, "establish", events=FANTASY)
before_state = _document(client)
before_events = len(_events(client))
ai_before = _ai_ids(client)
def explode(*a, **k):
raise RuntimeError("state persistence failed")
monkeypatch.setattr(adventures.turns.narrative.store, "record", explode)
with pytest.raises(RuntimeError):
_play(client, "the turn that fails", events=[
{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
# A05 deliberately keeps the player's submitted text so it can be tried
# again, so what L01 forbids is narrower and sharper: no *narration* was
# accepted, no state moved, and no event was recorded.
assert _ai_ids(client) == ai_before, "a narration was accepted"
assert _document(client) == before_state, "state moved for a turn that failed"
assert len(_events(client)) == before_events, "an event outlived its turn"
def test_l01_the_events_and_the_snapshot_land_with_the_turn(client):
"""The positive half: one commit, everything present after it."""
_play(client, "establish", events=FANTASY)
db = SessionLocal()
try:
action = (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
assert isinstance(action.narrative_state_after, dict), "no snapshot"
assert model.owner_of(action.narrative_state_after, "silver-key") == "aldric"
assert db.query(models.StateEvent).filter_by(action_id=action.id).count() \
== len(FANTASY)
assert db.query(models.StateProposal).filter_by(action_id=action.id).count() == 1
finally:
db.close()
# ============================================ L02 positional reconstruction
def test_l02_state_at_every_restored_position_is_what_was_accepted_there(client):
"""L02, measured in both directions. The state at a position is the state
that position produced — arriving from in front of it and from behind it
must agree."""
_play(client, "establish", events=FANTASY)
owners = ["aldric"]
for owner in ("mara", "aldric", "mara"):
_play(client, f"pass to {owner}", events=[
{"type": "set_possession", "item": "silver-key", "owner": owner}])
owners.append(owner)
going_back = []
for _ in range(len(owners) - 1):
client.post(f"/api/adventures/{client.adv_id}/undo")
going_back.append(model.owner_of(_document(client), "silver-key"))
coming_forward = []
for _ in range(len(owners) - 1):
client.post(f"/api/adventures/{client.adv_id}/redo")
coming_forward.append(model.owner_of(_document(client), "silver-key"))
assert going_back == list(reversed(owners[:-1]))
assert coming_forward == owners[1:]
def test_restoring_a_position_is_a_row_read_not_a_replay(client):
"""The performance property M3/M4 made load-bearing
(`TECHNICAL-DESIGN.md` §10.4): a restore must not scale with the campaign.
Asserted on query count rather than on time, because a timing assertion in a
test suite is a flake waiting to happen. An event-replay implementation
would have to read the event log, and the count would grow with the story.
"""
from sqlalchemy import event as sa_event
_play(client, "establish", events=FANTASY)
for n in range(8):
_play(client, f"turn {n}", events=[
{"type": "set_entity_attribute", "entity": "aldric",
"attribute": "steps", "value": n}])
seen = []
def record(conn, cursor, statement, params, context, executemany):
seen.append(statement)
sa_event.listen(engine, "before_cursor_execute", record)
try:
client.post(f"/api/adventures/{client.adv_id}/undo")
finally:
sa_event.remove(engine, "before_cursor_execute", record)
assert not any("state_events" in q for q in seen), (
"restoring read the event log — that is a replay, not a snapshot"
)
# ============================================ J01-J03 genre neutrality
SCIFI = [
{"type": "create_entity", "entity": "persephone", "entity_type": "vehicle",
"name": "the Persephone"},
{"type": "create_entity", "entity": "ceres-station", "entity_type": "location",
"name": "Ceres Station"},
{"type": "create_entity", "entity": "helios-combine",
"entity_type": "organization", "name": "the Helios Combine"},
{"type": "create_entity", "entity": "data-crystal", "entity_type": "item",
"name": "the encrypted data crystal"},
{"type": "create_entity", "entity": "vela", "entity_type": "character",
"name": "Vela"},
{"type": "set_current_location", "entity": "persephone",
"location": "ceres-station"},
{"type": "set_possession", "item": "data-crystal", "owner": "vela"},
{"type": "add_fact", "predicate": "cannot exceed light speed",
"subject": "persephone", "fact_id": "no-ftl"},
{"type": "add_relationship", "source": "vela", "target": "helios-combine",
"relationship": "works for"},
{"type": "open_story_thread", "thread": "decrypt-the-crystal",
"title": "Decrypt the data crystal"},
]
def test_j01_j02_a_science_fiction_campaign_uses_the_same_schema(client):
"""J01 and J02. A ship, a corporation, a station and a data crystal, with no
schema change, no new table and no genre-specific branch."""
_play(client, "dock", events=SCIFI)
document = _document(client)
assert document["entities"]["persephone"]["type"] == "vehicle"
assert document["entities"]["helios-combine"]["type"] == "organization"
assert document["entities"]["ceres-station"]["type"] == "location"
assert document["entities"]["data-crystal"]["type"] == "item"
assert document["entities"]["persephone"]["location"] == "ceres-station"
assert model.owner_of(document, "data-crystal") == "vela"
assert "decrypt-the-crystal" in model.open_threads(document)
def test_j03_only_the_data_differs_between_the_two_genres(client):
"""J03. The two fixtures exercise the identical event vocabulary, and the
documents they produce have identical structure — the difference is names."""
fantasy = napply.apply_events(model.empty(), FANTASY)
scifi = napply.apply_events(model.empty(), SCIFI)
def shape(document):
return {
key: type(value).__name__ for key, value in document.items()
}
assert shape(fantasy) == shape(scifi)
assert {e["type"] for e in FANTASY} <= set(nevents.ALLOWED)
assert {e["type"] for e in SCIFI} <= set(nevents.ALLOWED)
# And nothing in the application names a genre.
from pathlib import Path
package = Path(__file__).resolve().parents[1] / "app" / "narrative"
source = "\n".join(p.read_text(encoding="utf-8") for p in package.glob("*.py"))
import re as _re
for word in ("resurrect", "magic", "sword", "hit point", "spaceship",
"faster-than-light", "spell", "armor", "mana", "dungeon"):
# Word boundaries, because "misspelled" is not a genre assumption.
assert not _re.search(rf"\b{_re.escape(word)}", source.lower()), (
f"the state engine names {word!r}"
)
def test_j03_the_inspector_labels_categories_it_was_not_taught(client):
"""A campaign inventing its own entity category must not fall into "Other"."""
_play(client, "dock", events=SCIFI + [
{"type": "create_entity", "entity": "the-drift", "entity_type": "phenomenon",
"name": "the Drift"}])
titles = [g["title"] for g in _state(client)["groups"]]
assert "Vehicles" in titles
assert "Organizations" in titles
assert any(t.lower().startswith("phenomenon") for t in titles), titles
# ============================================== the inspector and the prompt
def test_the_inspector_shows_only_what_the_campaign_has(client):
empty = _state(client)
assert empty["empty"] is True and empty["groups"] == []
_play(client, "establish", events=FANTASY)
view = _state(client)
titles = [g["title"] for g in view["groups"]]
assert "Characters" in titles and "Locations" in titles
assert "Relationships" not in titles, "a category with nothing in it was shown"
rows = next(g for g in view["groups"] if g["title"] == "Characters")["rows"]
assert any(r["key"] == "aldric" and "Old Abbey" in r["detail"] for r in rows)
def test_the_prompt_carries_the_state_and_the_vocabulary(client):
_play(client, "establish", events=FANTASY)
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 "set_possession" in prompt, "the model was not told the vocabulary"
assert "ABSOLUTE" in prompt, "the model was not told values are absolute"
def test_the_emit_rule_only_describes_events_that_exist():
"""Generated from the allowlist, so the instruction cannot drift into
describing an event the application would then reject."""
rule = extract.EMIT_RULE
for name in nevents.ALLOWED:
assert name in rule
assert "increment" not in rule.lower(), "an ambiguous operation was described"
# ================================================== extraction and the prose
@pytest.mark.parametrize("reply, prose", [
('Story.\n```state\n{"events": []}\n```', "Story."),
('Story.\n```json\n{"events": []}\n```', "Story."),
('Story.\n```\n{"events": []}\n```', "Story."),
('Story.\n{"events": []}', "Story."),
('Story with a brace }', "Story with a brace }"),
])
def test_the_block_is_separated_from_the_prose(reply, prose):
text, _parsed, _raw = extract.split(reply)
assert text == prose
def test_trailing_prose_that_is_not_a_proposal_is_left_alone():
"""Removing a sentence from someone's story to satisfy a regex is worse
than leaving a stray brace in it."""
reply = 'She said, "the vault is sealed {for now}"'
text, parsed, _raw = extract.split(reply)
assert text == reply
assert parsed is None
def test_tolerant_parsing_repairs_only_unambiguous_mistakes():
text, parsed, _ = extract.split(
'Story.\n```state\n{"events": [{"type": "add_fact", "predicate": "x",}],}\n```')
assert parsed == {"events": [{"type": "add_fact", "predicate": "x"}]}
# ================================ D10 / §14-15: the narrator edit re-evaluates
def _edit(client, action_id, text):
return client.patch(
f"/api/adventures/{client.adv_id}/actions/{action_id}",
json={"text": text},
)
def _last_ai(client):
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc())
.first()
)
finally:
db.close()
def test_d10_editing_a_narration_re_derives_its_state(client):
"""`STORY-BRANCH-SEMANTICS.md` §15, and the half of D10 M5 owns.
The invariant, exactly: corrected authoritative prose and authoritative
structured state must not disagree because the edit changed only the text.
"""
_play(client, "establish", events=FANTASY)
_play(client, "she dresses",
reply="Mara pulls on a red cloak.",
events=[{"type": "set_entity_attribute", "entity": "mara",
"attribute": "clothing", "value": "red cloak"}])
assert _document(client)["entities"]["mara"]["attributes"]["clothing"] == "red cloak"
edited = _last_ai(client)
r = _edit(client, edited.id,
'Mara pulls on a green cloak.\n' + state_block([
{"type": "set_entity_attribute", "entity": "mara",
"attribute": "clothing", "value": "green cloak"}]))
assert r.status_code == 200, r.text
assert _document(client)["entities"]["mara"]["attributes"]["clothing"] == "green cloak"
# The block the user pasted in does not become part of the story.
assert "```state" not in r.json()["text"]
assert "green cloak" in r.json()["text"]
def test_an_edit_re_derives_from_the_state_before_the_turn(client):
"""Not from the campaign's current state. An edit replaces what this turn
established; it must not stack on top of what the turn already did."""
_play(client, "establish", events=FANTASY)
_play(client, "she moves",
events=[{"type": "set_current_location", "entity": "mara",
"location": "old-abbey"}])
assert _document(client)["entities"]["mara"]["location"] == "old-abbey"
# The edited narration says she stayed put, and proposes nothing.
edited = _last_ai(client)
_edit(client, edited.id, "Mara does not move.")
assert _document(client)["entities"]["mara"]["location"] is None, (
"the edit inherited the state its own turn had established"
)
def test_an_edit_leaves_the_audit_trail_of_both_versions(client):
"""Nothing is rewritten backwards: the events the original narration
produced stay in the log, and the correction's are appended beside them."""
_play(client, "establish", events=FANTASY)
_play(client, "she dresses",
events=[{"type": "set_entity_attribute", "entity": "mara",
"attribute": "clothing", "value": "red cloak"}])
before = len(_events(client))
edited = _last_ai(client)
_edit(client, edited.id,
'Green.\n' + state_block([
{"type": "set_entity_attribute", "entity": "mara",
"attribute": "clothing", "value": "green cloak"}]))
events = _events(client)
assert len(events) > before, "the edit recorded nothing"
assert any(e["source"] == "narrator_edit" for e in events)
# Both readings are still on the record.
values = [e["payload"].get("value") for e in events
if e["event_type"] == "set_entity_attribute"]
assert "red cloak" in values and "green cloak" in values
def test_an_edit_whose_proposal_is_refused_establishes_nothing(client):
"""A refused proposal inside an edit must not leave a half-state behind.
The edited turn now establishes nothing — its narration replaced the one
that set the cloak, and what the new narration proposed was refused — so the
attribute is gone rather than left at either value. That is the coherent
outcome: prose and state agree that this turn established nothing, which is
the invariant, rather than the state keeping a claim no narration makes.
"""
_play(client, "establish", events=FANTASY)
_play(client, "she dresses",
events=[{"type": "set_entity_attribute", "entity": "mara",
"attribute": "clothing", "value": "red cloak"}])
assert _document(client)["entities"]["mara"]["attributes"]["clothing"] == "red cloak"
edited = _last_ai(client)
r = _edit(client, edited.id,
'Green.\n' + state_block([
{"type": "set_entity_attribute", "entity": "nobody",
"attribute": "clothing", "value": "green cloak"}]))
assert r.status_code == 200, r.text
attributes = _document(client)["entities"]["mara"]["attributes"]
assert "clothing" not in attributes, (
"a claim survived the narration that made it"
)
assert "green cloak" not in str(_document(client)), (
"a refused value reached the state"
)
# And the refusal is on the record rather than silent.
proposal = _proposals(client.adv_id)[-1]
assert proposal.status == "rejected"
assert proposal.source == "narrator_edit"
# ------------------------------------------------------------------ D10 / §§14-15
#
# The M5 corrective pass. The review (Finding 1) found a narrator edit rewinding
# the campaign's live state to the edited turn while the head stayed at the tip,
# so the reader saw a full transcript over a state document describing an
# earlier moment. These tests hold the invariant that failure broke:
#
# visible active transcript position == stored head == authoritative state
def _texts(client) -> list[str]:
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
return [a["text"] for a in r.json()["actions"]]
def _head_of(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 _live_state_matches_head_snapshot(client) -> bool:
"""The invariant, read straight out of the database."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# Through the lineage, not by branch id: after a fork the head branch
# owns one node and inherits the rest of the path from its parent.
node = head.node_at(db, adventure, adventure.head_depth)
assert node is not None, "the head rests on no node"
return model.normalize(adventure.narrative_state) == model.normalize(
node.narrative_state_after
)
def test_editing_a_narrator_turn_with_visible_descendants_forks(client):
"""The review's reproduction, as a regression.
Four turns, each establishing something, then the *earliest* narrator turn
is corrected while every later turn is still on screen. The correction has
to become a new continuation rather than a rewind of the line being read.
"""
_play(client, "establish", events=FANTASY)
_play(client, "she dresses", events=[
{"type": "set_entity_attribute", "entity": "mara", "attribute": "cloak",
"value": "red"}])
_play(client, "walk on", events=[
{"type": "set_entity_attribute", "entity": "mara", "attribute": "boots",
"value": "muddy"}])
target = [a for a in _rows_all(client) if a.type == "ai"][1]
target_id, original_text = target.id, target.text
descendants = [
a.id for a in _rows_all(client) if (a.depth or 0) > (target.depth or 0)
]
assert descendants, "the fixture needs visible story below the edited turn"
branch_before, _ = _head_of(client)
r = _edit(client, target_id, "Mara pulls on a green cloak.\n" + state_block([
{"type": "set_entity_attribute", "entity": "mara", "attribute": "cloak",
"value": "green"}]))
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows_all(client)}
# §15.5-6: nothing on the old line was written to.
assert rows[target_id].text == original_text
assert all(old_id in rows for old_id in descendants)
# §15.4: a new active continuation, and the head is on it.
branch_after, depth_after = _head_of(client)
assert branch_after != branch_before, "the correction did not fork"
assert depth_after == target.depth
# §15.1-3: state re-derived from the turn's own starting point.
mara = _document(client)["entities"]["mara"]["attributes"]
assert mara["cloak"] == "green"
assert "boots" not in mara, "state from the abandoned future stayed current"
# The invariant.
assert _live_state_matches_head_snapshot(client)
def test_the_corrected_text_is_used_verbatim_not_regenerated(client):
"""§15.2. The reader's words are the narration; no model is called."""
_play(client, "establish", events=FANTASY)
ScriptedProvider.replies = ["THE MODEL SHOULD NOT BE CALLED."]
target = [a for a in _rows_all(client) if a.type == "ai"][0]
r = _edit(client, target.id, "Mara sets the key down, exactly so.")
assert r.status_code == 200, r.text
assert r.json()["text"] == "Mara sets the key down, exactly so."
assert "THE MODEL SHOULD NOT BE CALLED." not in _texts(client)
def test_the_old_narration_and_its_future_leave_the_active_transcript(client):
"""§15.4-5: retained, but not read. The old line keeps its rows; the story
on screen is the corrected one."""
_play(client, "establish", events=FANTASY)
_play(client, "she learns", events=[
{"type": "add_fact", "subject": "mara", "predicate": "knows the code",
"fact_id": "code"}])
target = [a for a in _rows_all(client) if a.type == "ai"][0]
_edit(client, target.id, "A different opening entirely.")
visible = _texts(client)
assert "A different opening entirely." in visible
assert not any("knows the code" in t for t in visible)
facts = [f["predicate"] for f in _document(client)["facts"]]
assert "knows the code" not in facts
assert _live_state_matches_head_snapshot(client)
def test_editing_the_latest_narrator_turn_still_works(client):
"""At the tip the correction is a take: nothing was built on the turn, so no
branch is needed, and the original stays in the pager (§15.5)."""
_play(client, "establish", events=FANTASY)
target = [a for a in _rows_all(client) if a.type == "ai"][-1]
original_text = target.text
branch_before, depth_before = _head_of(client)
r = _edit(client, target.id, "Corrected at the tip.\n" + state_block([
{"type": "add_fact", "predicate": "the tip was corrected",
"fact_id": "tip"}]))
assert r.status_code == 200, r.text
assert _head_of(client) == (branch_before, depth_before), "no branch needed"
rows = {a.id: a for a in _rows_all(client)}
assert rows[target.id].text == original_text, "the original take is retained"
assert not rows[target.id].live
assert "Corrected at the tip." in _texts(client)
assert "the tip was corrected" in [f["predicate"] for f in _document(client)["facts"]]
assert _live_state_matches_head_snapshot(client)
def test_an_edit_is_safe_when_a_future_is_off_screen(client):
"""What §14A refused, §§14-15 handle.
The refusal existed because an in-place edit changed the words an invisible
stretch of story was written from. A fork writes nothing to that line, so
the case is no longer unsafe — it is ordinary.
"""
_play(client, "establish", events=FANTASY)
_play(client, "second", events=[])
_play(client, "third", events=[])
target = [a for a in _rows_all(client) if a.type == "ai"][0]
original_text = target.text
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
off_screen = [
a.id for a in _rows_all(client) if (a.depth or 0) > (target.depth or 0)
]
r = _edit(client, target.id, "Something else entirely.")
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows_all(client)}
assert rows[target.id].text == original_text, "the off-screen line kept its words"
assert all(old_id in rows for old_id in off_screen), "and kept its story"
assert "Something else entirely." in _texts(client)
assert _live_state_matches_head_snapshot(client)
def test_undo_and_redo_after_an_edit_stay_on_the_corrected_lineage(client):
"""The consequence the review found worst: Undo restoring a snapshot from a
line the reader is no longer on, so the prose said green and the state said
red. Every position visited here has to agree with itself."""
_play(client, "establish", events=FANTASY)
_play(client, "she dresses", events=[
{"type": "set_entity_attribute", "entity": "mara", "attribute": "cloak",
"value": "red"}])
_play(client, "walk on", events=[
{"type": "set_entity_attribute", "entity": "mara", "attribute": "boots",
"value": "muddy"}])
target = [a for a in _rows_all(client) if a.type == "ai"][1]
_edit(client, target.id, "Mara pulls on a green cloak.\n" + state_block([
{"type": "set_entity_attribute", "entity": "mara", "attribute": "cloak",
"value": "green"}]))
assert _live_state_matches_head_snapshot(client)
for _ in range(2):
client.post(f"/api/adventures/{client.adv_id}/undo")
assert _live_state_matches_head_snapshot(client)
# Nothing from the abandoned line may reappear.
mara = _document(client)["entities"].get("mara", {}).get("attributes", {})
assert mara.get("cloak") != "red", "a snapshot from the old line was restored"
assert "boots" not in mara
for _ in range(2):
client.post(f"/api/adventures/{client.adv_id}/redo")
assert _live_state_matches_head_snapshot(client)
assert _document(client)["entities"]["mara"]["attributes"]["cloak"] == "green"
def _rows_all(client):
db = SessionLocal()
try:
return (
db.query(models.Action)
.filter_by(adventure_id=client.adv_id)
.order_by(models.Action.depth, models.Action.id)
.all()
)
finally:
db.close()
# ============================================ export / import round trip
def test_the_bundle_carries_the_state_and_its_snapshots(client):
"""M5 must not make export silently lose the authoritative state.
Both halves matter. The campaign's document is what the imported story
believes; the per-node snapshots are what makes its history walkable, and a
bundle carrying the turns without them would import a story that reads
correctly and then restores to the wrong state.
"""
_play(client, "establish", events=FANTASY)
_play(client, "hand it over", events=[
{"type": "set_possession", "item": "silver-key", "owner": "mara"}])
_canon(client.adv_id, {"rules": ["The dead do not return."]})
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
assert model.owner_of(bundle["narrativeState"], "silver-key") == "mara"
assert bundle["campaignCanon"]["rules"] == ["The dead do not return."]
assert any("narrativeStateAfter" in node for node in bundle["actions"])
imported = client.post("/api/adventures/import", json=bundle)
assert imported.status_code == 201, imported.text
new_id = imported.json()["id"]
r = client.get(f"/api/adventures/{new_id}/state")
assert model.owner_of(r.json()["document"], "silver-key") == "mara"
# And the imported history is walkable: undo restores the earlier state
# from the snapshot that arrived with it.
assert client.post(f"/api/adventures/{new_id}/undo").status_code == 200
r = client.get(f"/api/adventures/{new_id}/state")
assert model.owner_of(r.json()["document"], "silver-key") == "aldric"
def test_a_pre_m5_bundle_imports_with_an_empty_state(client):
"""Backward compatibility. A file written before M5 has no state section,
and a campaign that had none is what it records — so it opens, and it opens
with nothing established rather than with something invented."""
_play(client, "establish", events=FANTASY)
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
del bundle["narrativeState"]
del bundle["campaignCanon"]
for node in bundle["actions"]:
node.pop("narrativeStateAfter", None)
node.pop("stateChanges", None)
imported = client.post("/api/adventures/import", json=bundle)
assert imported.status_code == 201, imported.text
new_id = imported.json()["id"]
r = client.get(f"/api/adventures/{new_id}/state")
assert r.json()["empty"] is True
# The story itself arrived intact.
assert len(client.get(f"/api/adventures/{new_id}").json()["actions"]) > 1
def test_importing_state_does_not_move_the_active_head(client):
"""The head still comes from the bundle's own `headDepth` (M3/M4)."""
_play(client, "establish", events=FANTASY)
_play(client, "second", events=[])
_play(client, "third", events=[])
client.post(f"/api/adventures/{client.adv_id}/undo")
undone = [a["text"] for a in client.get(
f"/api/adventures/{client.adv_id}").json()["actions"]]
imported = client.post(
"/api/adventures/import",
json=client.get(f"/api/adventures/{client.adv_id}/export").json())
new_id = imported.json()["id"]
assert [a["text"] for a in client.get(
f"/api/adventures/{new_id}").json()["actions"]] == undone
assert client.get(f"/api/adventures/{new_id}").json()["can_redo"] is True
# ================================================================ migration
def test_a_pre_m5_database_opens_and_keeps_its_story():
"""M5 changes a load-bearing persistent subsystem, so a pre-M5 database has
to survive it: the transcript, the branches, the head, the retries and the
Save Points all still there, and no narrative state invented for them.
The RPG numbers are deliberately **not** translated. `player.gold = 70` says
nothing about who anyone is, where they stand or what they hold, and a fact
conjured from it would be fiction the campaign never established.
"""
import os
import tempfile
from sqlalchemy import create_engine, inspect, text as sql
from sqlalchemy.orm import sessionmaker
from app import migrations
path = os.path.join(tempfile.mkdtemp(), "m4.db")
old = create_engine(f"sqlite:///{path}")
Base.metadata.create_all(bind=old)
session = sessionmaker(bind=old)()
try:
owner = models.User(is_guest=False, email="m4@example.com")
session.add(owner)
session.flush()
adventure = models.Adventure(
user_id=owner.id, title="An M4 campaign", head_depth=2,
world_state={"player": {"gold": 70, "hp": 40}},
)
session.add(adventure)
session.flush()
branch = models.Branch(adventure_id=adventure.id, lineage=[])
session.add(branch)
session.flush()
branch.lineage = [[branch.id, None]]
adventure.head_branch_id = branch.id
for depth in range(3):
session.add(models.Action(
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
type="ai" if depth % 2 else "do", text=f"row {depth}", live=True,
world_state_after={"player": {"gold": depth * 10}},
))
session.add(models.Checkpoint(
adventure_id=adventure.id, name="Before the abbey",
branch_id=branch.id, depth=1))
session.commit()
adventure_id = adventure.id
finally:
session.close()
# Make it an M4 *schema*: no M5 tables or columns, stamped at M4's version.
with old.begin() as conn:
conn.execute(sql("DROP TABLE state_events"))
conn.execute(sql("DROP TABLE state_proposals"))
for column in ("narrative_state", "campaign_canon"):
conn.execute(sql(f"ALTER TABLE adventures DROP COLUMN {column}"))
for column in ("narrative_state_after", "state_changes"):
conn.execute(sql(f"ALTER TABLE actions DROP COLUMN {column}"))
conn.execute(sql("PRAGMA user_version = 80"))
migrations.bootstrap(old)
inspector = inspect(old)
assert "state_events" in inspector.get_table_names()
assert "state_proposals" in inspector.get_table_names()
assert "narrative_state" in {c["name"] for c in inspector.get_columns("adventures")}
assert "narrative_state_after" in {c["name"] for c in inspector.get_columns("actions")}
with old.connect() as conn:
assert conn.execute(sql("PRAGMA user_version")).scalar() == migrations.LATEST_VERSION
# The story, the head, the branch and the Save Point all survived.
title, head_depth, head_branch = conn.execute(sql(
"SELECT title, head_depth, head_branch_id FROM adventures")).one()
assert (title, head_depth) == ("An M4 campaign", 2)
assert head_branch is not None
assert conn.execute(sql("SELECT COUNT(*) FROM actions")).scalar() == 3
assert conn.execute(sql("SELECT COUNT(*) FROM checkpoints")).scalar() == 1
# Nothing was invented from the RPG numbers...
assert conn.execute(sql("SELECT COUNT(*) FROM state_events")).scalar() == 0
assert conn.execute(sql("SELECT narrative_state FROM adventures")).scalar() is None
# ...and the legacy values are still there, unread and unharmed.
assert '"gold": 70' in conn.execute(sql(
"SELECT world_state FROM adventures")).scalar()
# Idempotent.
migrations.bootstrap(old)
assert "state_events" in inspect(old).get_table_names()
old.dispose()
del adventure_id
# ========================= protocol the block extraction could not remove
#
# Both cases below were found by the M5 realistic-context run against a real
# local model (§12), not imagined. They are the Phase 0B failure class exactly:
# behaviour that is correct on a small prompt and wrong under a full one.
def test_an_echoed_instruction_does_not_reach_the_reader():
"""A small model reproduces the bracketed reminder it was given, as prose.
It arrives with no fence, so nothing that looks for a block strips it, and
the reader would be shown a piece of the prompt.
"""
reply = (
"You and Mara talk a while longer.\n\n"
"[Reminder: end your reply with only the ```state block, "
"without any additional text.]"
)
prose, parsed, _raw = extract.split(reply)
assert prose == "You and Mara talk a while longer."
assert parsed is None
def test_a_fence_the_model_never_closed_is_not_story():
"""A model that runs out of output tokens mid-block leaves a dangling
opener. Everything after it is protocol, so the story ends where it
begins — otherwise a truncated turn shows the reader half a JSON array."""
reply = 'The rain falls harder.\n```state\n{"events": [{"type": "add_fact"'
prose, _parsed, _raw = extract.split(reply)
assert prose == "The rain falls harder."
assert "```" not in prose
# ------------------------------------------- fence safety (review Finding 6)
#
# The extractor may remove the application's own protocol payload and nothing
# else. A story is allowed to contain code, and to talk about the protocol.
def test_a_state_fence_is_always_ours_even_when_it_does_not_parse():
prose, parsed, raw = extract.split('Beat.\n```state\n{oh no')
assert prose == "Beat."
assert parsed is None
assert raw, "the unparseable payload is kept for the audit"
def test_an_ordinary_json_code_block_stays_in_the_story():
"""The review's example: a character typing JSON into a terminal kept
losing their code block, because `json` was treated as our label."""
reply = ('She typed it out:\n```json\n{"name": "Mara"}\n```\n'
'Then closed the terminal.')
prose, parsed, _raw = extract.split(reply)
assert prose == reply
assert parsed is None
def test_a_json_fence_that_really_is_a_proposal_is_still_taken():
"""Models reach for the wrong label; the payload decides, not the word."""
reply = ('Beat.\n```json\n{"events": [{"type": "add_fact", '
'"predicate": "the door opened"}]}\n```')
prose, parsed, _raw = extract.split(reply)
assert prose == "Beat."
assert parsed["events"][0]["predicate"] == "the door opened"
def test_a_malformed_proposal_in_a_json_fence_never_reaches_the_reader():
"""Caught by the realistic-model run during the corrective pass.
A small model mangled its own JSON inside a ```json fence. Judging the fence
only by whether it parsed left the wreckage in the story. A block that
plainly reads as protocol is ours whether or not it parses — the same rule
a ```state fence has always had.
"""
reply = ('Beat.\n```json\n{"events": [{"type": "set_current_location", '
'"entity": "you",,}]\n```')
prose, parsed, raw = extract.split(reply)
assert prose == "Beat."
assert '"events"' not in prose
assert raw, "the malformed payload is still kept for the audit"
def test_a_code_fence_in_another_language_is_never_touched():
reply = 'He wrote:\n```python\nprint("hi")\n```\nand ran it.'
prose, parsed, _raw = extract.split(reply)
assert prose == reply
assert parsed is None
def test_an_unlabelled_json_shaped_block_that_is_not_a_proposal_stays():
reply = 'The config read:\n```\n{"name": "Mara"}\n```\nand nothing else.'
prose, _parsed, _raw = extract.split(reply)
assert prose == reply
def test_prose_that_merely_mentions_the_protocol_is_kept():
"""A bracketed aside naming the state block is a sentence in someone's
story until it looks like the instruction itself."""
reply = "She frowned. [He was still thinking about the state block.]"
prose, _parsed, _raw = extract.split(reply)
assert prose == reply
def test_a_dangling_json_fence_that_is_not_a_proposal_is_kept():
"""An unterminated code block in a story is still the author's."""
reply = 'She typed it out:\n```json\n{"name": "Mara"}'
prose, _parsed, _raw = extract.split(reply)
assert prose == reply
def test_a_dangling_json_fence_that_is_a_truncated_proposal_is_cut():
reply = 'Beat.\n```json\n{"events": [{"type": "add_fact"'
prose, _parsed, _raw = extract.split(reply)
assert prose == "Beat."
@pytest.mark.parametrize("reply", [
'She said, "the vault is sealed" [and it was]',
"He counted the coins [there were nine] and shrugged.",
"Plain prose with no protocol at all.",
])
def test_ordinary_prose_is_never_trimmed(reply):
"""The cleanup is narrow on purpose. Removing a sentence from someone's
story to satisfy a regex is a worse failure than leaving a stray bracket."""
prose, _parsed, _raw = extract.split(reply)
assert prose == reply