Stop paying twice for a block a retry can still throw away

A memory whose block ends on the newest action is the one memory a player
can reach: retry and take-switching both refuse anything else. Each retry
of that turn withdrew the memory and wrote it again, and a block closes
every six actions while a normal turn writes two, so that was one turn in
three.

SETTLE_SLACK asks for one action past a block before the block is
summarized. The block is still MEMORY_INTERVAL actions; only the moment
moves. This is not the pre-SP4 holdback returning: that one was about a
retry rewriting text in place, which sibling attempts and forget_node
settled, and correctness still rests on the withdrawal rather than on the
slack. Undo and delete can carry a summarized node back to the tip, so the
withdrawal path stays reachable, just rarely.

The slack buys nothing back. The block that just closed is still in the
history window in full, so a memory of it says what the model can already
read.

Tests: the settling suite asserts the new rule and that a retry at the tip
finds nothing to withdraw; the rewrite suite builds thirteen actions so
both of its blocks settle; the one path test that ended on a block boundary
sets the slack to zero, because it is about which actions a block is read
from rather than about when a block forms. 632 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NcrxCJjqgDvAkamKeLWdn
This commit is contained in:
Claude
2026-09-01 19:30:56 +05:30
committed by Parth
parent 745a4ea9f3
commit d72f7c1bda
9 changed files with 178 additions and 31 deletions
+32 -3
View File
@@ -8,7 +8,8 @@ database session. It does three things:
- Every `MEMORY_INTERVAL` actions, starting once the adventure reaches
`MEMORY_START` actions, it summarizes each uncovered block of actions into a
short memory.
short memory. A block waits until `SETTLE_SLACK` actions sit past it, so a
memory never ends on the action a retry could replace.
- Every `SUMMARY_INTERVAL` actions, it rewrites the story summary to include the
new memories. The rewrite always starts from the text the user edited and
never discards it.
@@ -55,6 +56,32 @@ RETRIEVAL_WINDOW_ACTIONS = 4 # ...taken from this many of the newest actions
SUMMARY_MAX_WORDS = 250
MEMORY_EXCERPT_TOKENS = 2000 # of the block, when a block is longer than this
# How much story has to sit past a block before that block is summarized.
#
# This is not the pre-SP4 holdback returning. That rule made the newest action
# invisible to the summarizer everywhere, and it existed because a retry
# rewrote an action's text in place, so a memory covering the tip could end up
# describing narration the player had already replaced. Sibling attempts and
# `forget_node` settled that, and correctness does not rest on the number
# below: a delete or an undo anywhere in the story is still repaired by
# withdrawing what the coordinate produced.
#
# This is a cost rule, and it is only about the tip. Retry and take-switching
# both refuse anything but the newest action (`takes.retry_action`,
# `takes.switch_take`), and both withdraw the derived work at the coordinate
# they change. So a memory whose block ends on the tip is the one memory a
# player can still throw away, and every retry of that turn pays for it twice:
# once to write it, once to write it again. A block closes every
# MEMORY_INTERVAL actions and a normal turn writes two of them, so that is one
# turn in three.
#
# One action of slack moves the block end out of reach of both endpoints, and
# it buys nothing back in context quality: the block that just closed is still
# in the history window in full, so a memory of it tells the model what it can
# already read. Memories earn their place once the raw text has scrolled out,
# which is never the turn the block closed.
SETTLE_SLACK = 1
# ---- The cast brief (Phase 18b) ----
# How many characters the brief names, and how much of each description it
# carries. The cast is authored content rather than generated, so it is small in
@@ -613,8 +640,10 @@ async def _create_due_memories(
# the story, but this loop is the only code that moves the anchor, so
# both numbers must be current.
anchor = cursors.MEMORY.depth(db, adventure)
if history.count_after(adventure, anchor) < MEMORY_INTERVAL:
return # No full block of story sits past the mark.
if history.count_after(adventure, anchor) < MEMORY_INTERVAL + SETTLE_SLACK:
return # No settled block of story sits past the mark. The block
# itself is still MEMORY_INTERVAL actions; the slack asks
# for story past its end. See `SETTLE_SLACK`.
if history.count(adventure) < MEMORY_START:
return # The adventure is too short to have started summarizing.
# The order of those two checks is deliberate. The usual answer is that
+4 -1
View File
@@ -290,7 +290,10 @@ async def _generate_turn(
# longer describes the story. Withdraw the memory attached to the node
# and return that stretch to both passes. Before SP4 this code was
# unreachable, because the summarizer held the newest action back until
# a turn landed on top of it. See `memorybank`.
# a turn landed on top of it. `memorybank.SETTLE_SLACK` keeps a memory
# off the tip again, for cost rather than for correctness, so this is
# now the rare case: undo or delete can carry a summarized node back to
# the tip, and then a retry of it lands here. See `memorybank`.
memorybank.forget_node(db, adventure, retry_of)
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
# Flush so the new attempt has an id. The session does not autoflush,