Keep the state section and its proposal out of the story
The first complete M01 run with the memory bank on (f8d4010, 101 turns on
a GPU host) reported "complete". It still did not prove M04. The planted
clue was found at turn 100 only because the narrator had pasted the
narrative-state section into its own prose, and the paste was still in
recent history. No memory and no summary carried the clue.
The narrator is a small local model. It wrote protocol into its stored
narration on 42 of 104 turns, starting at depth 2, in four shapes:
- a copy of the state section: `Scene:`, `Who and what exists:`, `Held:`,
`Established:`, `Still open:`
- that copy above a correct ```state block, which was stripped while the
copy stayed
- the copy, then a bare `State` heading, then a `> {"events": ...}`
proposal quoted like a player turn, sometimes with story after it
- the same block cut off by the output-token limit, on 10 turns
Stored text is replayed verbatim as history, so each leak also put a second,
older account of the state into the next prompt. That is what M5 review
Finding 4 removed from history replay, and every leak gave the model another
example to copy.
The extractor now removes:
- a pasted state section, recognised by at least two of the renderer's own
headings as whole lines. The headings are constants in `render.py`, so the
renderer and the extractor cannot drift apart. One heading alone, or a
`Scene:` line of prose, is left.
- an unfenced proposal that starts a line, quoted or not, when it parses and
is a proposal. With no fence it becomes the turn's proposal. A `State`
heading directly above goes with it. Candidates are taken outermost first,
so a finished event line inside an unfinished block is never taken as a
proposal by itself.
- an unfinished unfenced proposal at the end that reads as protocol.
- whatever is left at the end: a `State` heading, a bare `>`, a parroted
reminder or continue hint (closed or not), and a ```json fence cut off
before it names its events. These are cut repeatedly until nothing more
comes off.
This also fixes an older bug. `_STATE_FENCE_RE` read "a ```state block"
inside a parroted reminder as a fence opening and cut out the middle of the
reminder. The label must now end its line or run straight into the payload.
A reply whose only removal is a pasted state section records no raw block,
so the turn is not marked unparseable for a block it never started.
Every AI turn in four real runs was replayed through the new extractor:
draco M01, the two 26-turn GPU trials, and this M01 run. 339 turns in all.
No turn the old extractor had left clean changed. Every leak of our own
protocol is gone: 42 of 42 in this M01 run, 5 in trial 2, 3 on draco.
Trial 1 still has model-invented headings ("Identifiers established:",
"Set of events made true:") on 10 turns. They paraphrase the instruction and
are not our renderer's text, so they are left, not guessed at.
The long-run harness now records an explicit M04 verdict, which is never a
recovery while the clue is still in recent history. It also counts the AI
turns in the export that still carry protocol.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VBTMUKWYeU6G9HgbDbND
This commit is contained in:
co-authored by
Claude Opus 5
parent
f8d401029f
commit
0c7316f951
@@ -30,7 +30,7 @@ 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.narrative import extract, model, render, store, validate
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider, state_block
|
||||
@@ -1558,3 +1558,236 @@ def test_ordinary_prose_is_never_trimmed(reply):
|
||||
story to satisfy a regex is a worse failure than leaving a stray bracket."""
|
||||
prose, _parsed, _raw = extract.split(reply)
|
||||
assert prose == reply
|
||||
|
||||
|
||||
# ============================ M11: the state section, pasted into the narration
|
||||
#
|
||||
# Found by the first M01 long run with the memory bank on. A small local model
|
||||
# pasted the narrative-state section into its prose on 42 of 104 turns, and
|
||||
# wrote its proposal unfenced under a bare `State` heading, sometimes quoted and
|
||||
# sometimes with more story after it. Stored text is replayed as history, so
|
||||
# each leak also put a second, older account of the state into the next prompt.
|
||||
# The replies below are cut down from that run's raw output, not imagined.
|
||||
|
||||
PASTED_STATE = (
|
||||
"Scene: Aldric, Mara, and Edrin in The Crooked Lantern, the silver key in "
|
||||
"Mara’s possession. (at The Crooked Lantern)\n"
|
||||
"\n"
|
||||
"Who and what exists:\n"
|
||||
" aldric: Aldric (character)\n"
|
||||
" mara: Mara (character)\n"
|
||||
" silver_key: the silver key (item)\n"
|
||||
"\n"
|
||||
"Held:\n"
|
||||
" the silver key — Mara\n"
|
||||
"\n"
|
||||
"Established:\n"
|
||||
" Aldric knows The Old Abbey the silver key opens the crypt beneath the Old "
|
||||
"Abbey (SILVER-KEY-CRYPT-OLD-ABBEY) [corrected by the player]"
|
||||
)
|
||||
|
||||
|
||||
def test_a_pasted_state_section_and_an_unfenced_trailing_block_leave_the_prose():
|
||||
reply = (
|
||||
"Edrin looks at them both. “Do you trust me, Mara?”\n\n"
|
||||
"> Aldric steps forward, placing the silver key in Mara’s hand. “I do.”\n\n"
|
||||
+ PASTED_STATE + "\n\nState\n"
|
||||
'{\n "events": [\n {"type": "set_possession", "item": "silver_key", '
|
||||
'"owner": "mara"}\n ]\n}'
|
||||
)
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == (
|
||||
"Edrin looks at them both. “Do you trust me, Mara?”\n\n"
|
||||
"> Aldric steps forward, placing the silver key in Mara’s hand. “I do.”"
|
||||
)
|
||||
assert parsed["events"][0]["owner"] == "mara"
|
||||
|
||||
|
||||
def test_a_quoted_block_in_the_middle_of_the_story_is_taken_and_removed():
|
||||
reply = (
|
||||
"Aldric steps forward. “We need to be cautious,” he says.\n\n"
|
||||
+ PASTED_STATE + "\n\nState\n\n"
|
||||
'> {"events": [{"type": "set_current_location", "entity": "aldric", '
|
||||
'"location": "ridge_track"}]}\n\n'
|
||||
"The forest ahead was dense, and the road lower than expected."
|
||||
)
|
||||
prose, parsed, raw = extract.split(reply)
|
||||
assert prose == (
|
||||
"Aldric steps forward. “We need to be cautious,” he says.\n\n"
|
||||
"The forest ahead was dense, and the road lower than expected."
|
||||
)
|
||||
assert parsed["events"][0]["location"] == "ridge_track"
|
||||
assert raw.startswith('{"events"'), "the proposal is kept for the audit"
|
||||
|
||||
|
||||
def test_a_pasted_state_section_above_a_proper_block_is_removed_too():
|
||||
reply = "The rain eases.\n\n" + PASTED_STATE + '\n\n```state\n{"events": []}\n```'
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == "The rain eases."
|
||||
assert parsed == {"events": []}
|
||||
|
||||
|
||||
def test_a_multi_line_quoted_block_is_read_without_its_markers():
|
||||
reply = (
|
||||
'Beat.\n\n> {\n> "events": [\n> {"type": "add_fact", '
|
||||
'"predicate": "the door opened"}\n> ]\n> }\n\nAfter.'
|
||||
)
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == "Beat.\n\nAfter."
|
||||
assert parsed["events"][0]["predicate"] == "the door opened"
|
||||
|
||||
|
||||
def test_every_section_the_renderer_writes_is_recognised_when_pasted():
|
||||
"""The extractor finds a paste by the renderer's own headings. A section
|
||||
added to `render.for_prompt` without a constant would leak again unnoticed,
|
||||
so this renders every section and pastes the lot."""
|
||||
document = model.empty()
|
||||
document["entities"] = {
|
||||
"mara": {"type": "character", "name": "Mara"},
|
||||
"key": {"type": "item", "name": "the key"},
|
||||
}
|
||||
document["possessions"] = {"key": "mara"}
|
||||
document["facts"] = [
|
||||
{"id": "f1", "subject": "mara", "predicate": "knows",
|
||||
"value": "the door is locked", "status": "active"},
|
||||
{"id": "f2", "subject": "mara", "predicate": "believed",
|
||||
"value": "the door was open", "status": "invalidated"},
|
||||
]
|
||||
document["relationships"] = [
|
||||
{"source": "mara", "type": "guards", "target": "key", "status": "active"}]
|
||||
document["threads"] = {"door": {"title": "Who locked the door", "status": "open"}}
|
||||
document["scene"] = {"summary": "Mara at the door.", "location": None,
|
||||
"present": ["mara"]}
|
||||
|
||||
rendered = render.for_prompt(document)
|
||||
lines = rendered.split("\n")
|
||||
for heading in render.SECTION_HEADINGS:
|
||||
assert heading in lines, f"{heading!r} is not a line of the rendered section"
|
||||
|
||||
prose, _parsed, _raw = extract.split("Mara listens.\n\n" + rendered)
|
||||
assert prose == "Mara listens."
|
||||
|
||||
|
||||
def test_a_quoted_block_cut_off_by_the_token_limit_is_not_story():
|
||||
"""10 of 104 turns in the long run ended inside a quoted object."""
|
||||
reply = (
|
||||
"The sky above is a canvas of gray, the rain relentless.\n\n"
|
||||
+ PASTED_STATE + "\n\nState\n\n"
|
||||
'> {"events": [{"type": "set_entity_status", "entity":'
|
||||
)
|
||||
prose, parsed, raw = extract.split(reply)
|
||||
assert prose == "The sky above is a canvas of gray, the rain relentless."
|
||||
assert parsed is None
|
||||
assert raw, "the unfinished block is kept for the audit"
|
||||
|
||||
|
||||
def test_a_block_missing_only_its_last_brace_is_not_story():
|
||||
reply = (
|
||||
"Mara’s eyes widen.\n\nState\n\n"
|
||||
'> {"events": [{"type": "add_fact", "predicate": "Mara understands."}]'
|
||||
)
|
||||
prose, _parsed, _raw = extract.split(reply)
|
||||
assert prose == "Mara’s eyes widen."
|
||||
|
||||
|
||||
def test_an_unfinished_block_is_cut_at_its_outer_brace_not_an_inner_one():
|
||||
"""The finished event objects close; the block around them never does.
|
||||
Cutting at the last line that opens an object left the list in the story."""
|
||||
reply = (
|
||||
"They brace themselves for what they must face.\n\nState\n{\n"
|
||||
' "events": [\n'
|
||||
' { "type": "set_current_location", "entity": "aldric", "location": "docks" },\n'
|
||||
' { "type": "set_entity_attribute", "entity": "aldric", "attribute": "mood", '
|
||||
'"value": "tense"\n'
|
||||
" ]\n}"
|
||||
)
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == "They brace themselves for what they must face."
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_a_finished_event_inside_an_unfinished_block_is_not_taken_on_its_own():
|
||||
reply = (
|
||||
'Beat.\n\nState\n{\n "events": [\n'
|
||||
' {"type": "add_fact", "predicate": "the door opened"}\n'
|
||||
)
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == "Beat."
|
||||
assert parsed is None, "one event line was taken as the whole proposal"
|
||||
|
||||
|
||||
def test_a_bare_quote_marker_left_at_the_end_is_not_story():
|
||||
reply = (
|
||||
"They are ready.\n\n"
|
||||
"[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.]\n\n>"
|
||||
)
|
||||
prose, _parsed, _raw = extract.split(reply)
|
||||
assert prose == "They are ready."
|
||||
|
||||
|
||||
def test_a_parroted_reminder_above_an_unfinished_json_fence_is_all_removed():
|
||||
"""The reminder names "a ```state block". That phrase once read as a fence
|
||||
opening, and the middle of the reminder was cut out while the rest of it
|
||||
and the unfinished JSON stayed in the story."""
|
||||
reply = (
|
||||
"He turned back towards the town instead.\n\n"
|
||||
"[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.]\n\n"
|
||||
'```json\n{\n "events": [\n {'
|
||||
)
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == "He turned back towards the town instead."
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_a_fence_cut_off_before_it_names_its_events_is_not_story():
|
||||
reply = (
|
||||
"The silver key was his only guide.\n\n"
|
||||
"[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.]\n\n"
|
||||
'```json\n{\n "'
|
||||
)
|
||||
prose, _parsed, _raw = extract.split(reply)
|
||||
assert prose == "The silver key was his only guide."
|
||||
|
||||
|
||||
def test_a_parroted_continue_hint_cut_off_mid_sentence_is_not_story():
|
||||
reply = (
|
||||
"Aldric's steps were firm.\n\n"
|
||||
"[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.]\n\n"
|
||||
"[Continue the story directly."
|
||||
)
|
||||
prose, _parsed, _raw = extract.split(reply)
|
||||
assert prose == "Aldric's steps were firm."
|
||||
|
||||
|
||||
def test_a_pasted_state_section_with_no_block_records_no_block():
|
||||
"""A paste is not a proposal, so the turn is not marked unparseable."""
|
||||
prose, parsed, raw = extract.split("The rain eases.\n\n" + PASTED_STATE)
|
||||
assert prose == "The rain eases."
|
||||
assert parsed is None
|
||||
assert raw == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reply", [
|
||||
"The notice on the door read:\n\nHeld:\n nothing, by order of the Watch.",
|
||||
"He chalked the first mark of a sum on the wall:\n{",
|
||||
"The sign said only [continue at your own risk",
|
||||
'She typed it out:\n```json\n{"name":',
|
||||
"Scene: the docks at dawn.\n\nThe gulls cried over the water.",
|
||||
'She typed:\n> {"name": "Mara"}\nand pressed enter.',
|
||||
"Aldric read the old charter.\n\nState\n\nof the realm, it began, is fragile.",
|
||||
"> You open the door.\n\nThe hinges complain.",
|
||||
])
|
||||
def test_story_that_only_resembles_the_protocol_is_kept(reply):
|
||||
"""One heading, a scene line on its own, JSON that is not a proposal, and a
|
||||
`State` line with story after it are all somebody's story."""
|
||||
prose, parsed, _raw = extract.split(reply)
|
||||
assert prose == reply
|
||||
assert parsed is None
|
||||
|
||||
Reference in New Issue
Block a user