Answer the review, and keep the opening node's bank

Nine findings from a review of the phase-14 stack. The one about a retry
withdrawing a memory is not a bug — a memory anchored to a node describes that
node, and it goes when the node goes. The root is the exception, and it is the
only one: migration 62 parked every memory written before memories had
coordinates on depth 0, so withdrawing the opening node would retire a whole
bank nobody attached there. A memory with no source range covers no story and
now stays; a summary that genuinely ends there is still withdrawn.

The rest are repairs.

* The adventure list quoted whichever attempt was written last rather than the
  one the story tells, so switching back left the index disagreeing with the
  page.
* A v1 import gave a typed memory no depth, rebuilding the NULL the migration
  exists to remove — invisible until the imported adventure forked.
* The action cap counted a v1 file's turns, and a turn expands into a row per
  saved attempt, so a file inside the cap could write a multiple of it.
* Forking a live node on a borrowed ancestor promoted a sibling on a branch the
  caller never named. It is a branch switch, and now says so.
* Switching attempts left the state, status and memory panels reading the
  previous take: the story does not change length, so nothing keyed on its
  length noticed. Same class as the branch-switch bug this phase already fixed.
* A retry after switching back numbered the new attempt into the middle of the
  group instead of the end.
* The cursor backfill numbered every action in the table once per adventure;
  correlated to the adventure being updated, it is an index lookup instead.
* Renaming a branch answered own_actions=0.

And one behaviour change recorded rather than repaired: script-visible history
and actionCount no longer count blank-text rows. That is the right shape and
there is no reading compatible with both, so plan/14 says so.

409 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent 2d38a162d4
commit 4af6e17406
14 changed files with 330 additions and 17 deletions
+50
View File
@@ -404,3 +404,53 @@ def test_export_carries_every_attempt_as_its_own_node(client):
ai_rows = [a for a in rows if a.type == "ai"]
assert [(a.text, a.live) for a in ai_rows] == [("One.", False), ("Two.", True)]
assert len({(a.branch_id, a.depth) for a in ai_rows}) == 1
def test_a_retry_after_switching_back_files_the_new_attempt_last(client):
"""The group stays in the order the attempts were made.
`add_attempt` used to number a new take one past the take it replaced, which
is the end of the group only when the story is standing on the newest one.
Switch a three-take turn back to the first and retry, and the new attempt
collided with take 2 — `renumber` then broke the tie by id and filed it
*between* takes 2 and 3, so the pager walked them in an order nobody played.
"""
ScriptedProvider.replies = ["One.", "Two.", "Three.", "Four."]
_play(client)
_retry(client)
_retry(client)
assert [a.text for a in _rows(client.adv_id) if a.type == "ai"] == [
"One.", "Two.", "Three."]
live = _page(client)["actions"][-1]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 0})
assert r.status_code == 200, r.text
_retry(client)
ai = [a for a in _rows(client.adv_id) if a.type == "ai"]
assert [a.text for a in ai] == ["One.", "Two.", "Three.", "Four."]
assert [a.variant_index for a in ai] == [0, 1, 2, 3]
assert attempts.live_in(ai).text == "Four."
def test_the_adventure_list_quotes_the_take_the_story_tells(client):
"""The index screen and the story have to agree.
Siblings share a depth and the newest of them has the highest id, so a
snippet ordered by `(depth, id)` alone quotes whichever attempt was written
last — which, after switching back, is the one the player threw away.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
live = _page(client)["actions"][-1]
assert live["text"] == "Two."
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
json={"index": 0})
listed = client.get("/api/adventures").json()
row = [a for a in listed if a["id"] == client.adv_id][0]
assert "One." in row["snippet"]
assert "Two." not in row["snippet"]
+8 -5
View File
@@ -321,10 +321,11 @@ def test_forking_a_turn_that_is_already_the_story_is_a_no_op(client):
assert len(_branches(client)) == 1
def test_forking_a_single_take_on_another_branch_is_refused(client):
"""The only way to reach the refusal, and it names the wrong tool: a node
with no siblings is not a divergence, so what the caller wants is to switch
to the branch it is on."""
def test_forking_a_live_node_on_another_branch_is_refused(client):
"""A live node off the path is another line's story, not an attempt going
spare — so the refusal names the tool that would actually do it. It used to
answer "only one take", which was true of the group and no help at all: the
caller does not want another take, it wants the branch this one is on."""
discarded = _divergent_story(client)
_fork(client, discarded)
parent_id = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
@@ -333,7 +334,9 @@ def test_forking_a_single_take_on_another_branch_is_refused(client):
r = _fork(client, stranded)
assert r.status_code == 400
assert "one take" in r.json()["detail"]
assert "another branch" in r.json()["detail"]
# And refusing left the tree alone — the bug this guards is a fork that
# promotes a sibling on the branch it was called against.
assert len(_branches(client)) == 2
+18
View File
@@ -222,6 +222,24 @@ def test_a_name_longer_than_the_column_is_refused(client):
assert _rename(client, forked, "x" * schemas.BRANCH_NAME_MAX).status_code == 200
def test_a_rename_hands_back_the_row_the_listing_would_give(client):
"""Renaming a branch does not change how many turns are on it.
`own_actions` was hard-coded to 0 in this response, which only stayed
invisible because the panel throws the body away and refetches. Anything
that trusted the reply would draw a branch that had just lost its turns.
"""
root, forked = _forked(client)
listed = {b["id"]: b for b in _branches(client)}
renamed = _rename(client, forked, "the cellar").json()
assert renamed["own_actions"] == listed[forked]["own_actions"]
assert renamed["own_actions"] > 0, "the fixture put turns on this branch"
assert renamed["depth"] == listed[forked]["depth"]
assert renamed["is_head"] == listed[forked]["is_head"]
def test_naming_a_branch_of_another_adventure_is_a_404(client):
root, forked = _forked(client)
db = SessionLocal()
+69
View File
@@ -533,6 +533,75 @@ def test_a_v2_bundle_brings_its_anchors_back(client):
db.close()
def test_a_v1_memory_that_summarises_nothing_lands_on_the_root(client):
"""The import has to answer the question migration 62 answered.
A v1 file has no depths, and a memory the player typed has no `sourceEnd`
to derive one from — so it used to come back with a NULL depth, which is the
exact state SP7 removed from the schema. `Path._entry_clause` compares
`depth <= max_depth` and a NULL fails it, so the memory would read fine
until the imported adventure was forked and then vanish from the new branch.
"""
payload = {
"format": bundle.LEGACY_FORMAT, "title": "Old backup",
"memoryCursor": 0, "summaryCursor": 0,
"actions": [
{"index": 0, "type": "start", "text": OPENING},
{"index": 1, "type": "story", "text": "A corridor."},
],
"memories": [
{"text": "Kira is the innkeeper's daughter"}, # typed
{"text": "The corridor, summarised", "sourceStart": 1, "sourceEnd": 1},
],
}
copy = _imported(client, payload)
db = SessionLocal()
try:
rows = {m.text: m for m in db.query(models.Memory)
.filter(models.Memory.adventure_id == copy).all()}
assert rows["Kira is the innkeeper's daughter"].depth == 0, (
"a typed memory anchors at the root, which every branch can see"
)
assert rows["The corridor, summarised"].depth == 1, "derived from its range"
assert all(m.depth is not None for m in rows.values())
assert all(m.branch_id is not None for m in rows.values())
finally:
db.close()
def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypatch):
"""The cap has to count what gets written, not what the file lists.
A v1 turn carries its retries in a `variants` array, and SP4 made every
attempt a row — so one entry can become ten. Counting entries lets a file
inside the cap write a multiple of it, and the body limit is no help: the
text is tiny, it is the row count that is the cost.
"""
monkeypatch.setattr(auth, "MULTI_USER", True)
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 6)
monkeypatch.setattr(limits, "_BUNDLE_LIST_CAPS",
{**limits._BUNDLE_LIST_CAPS, "actions": 6})
payload = {
"format": bundle.LEGACY_FORMAT, "title": "Small file, many rows",
"memoryCursor": 0, "summaryCursor": 0,
"actions": [{"index": 0, "type": "start", "text": OPENING}] + [
{
"index": i, "type": "ai", "text": "Take four.",
"variants": [{"text": f"Take {n}."} for n in range(4)],
"variantIndex": 3,
}
for i in range(1, 4)
],
}
assert len(payload["actions"]) <= 6, "the file itself is inside the cap"
before = _adventure_count()
r = _import(client, payload)
assert r.status_code == 409, r.text
assert _adventure_count() == before, "and nothing was written"
def test_an_unknown_format_is_refused(client):
r = _import(client, {"format": "ai-dnd-adventure-v3", "title": "From the future"})
assert r.status_code == 400, r.text
+50
View File
@@ -452,3 +452,53 @@ def test_retrieving_from_a_deep_fork_costs_what_a_flat_story_costs(deeply_forked
f"retrieval on a 20-fork story cost {forked_bytes:,} B against the "
f"{flat_bytes:,} B a flat story of the same length cost"
)
# ------------------------------------------------------- the opening node
def test_a_typed_memory_on_the_opening_node_survives_that_node_going(forked):
"""The one place a node and its memories part company.
A memory anchored to a node is withdrawn with the node, which is the rule
and is deliberate: it described that turn, and the turn is leaving. But
migration 62 parked *every* memory written before memories had coordinates
on depth 0 — the only landing spot visible from every branch — so the
opening node carries a whole bank it never produced. Withdrawing it would
retire all of that in one click, for every adventure predating the tree.
A memory with no `source_start` covers no stretch of story, so nothing about
it can go stale. It stays.
"""
db, adventure, settings, ids = forked
typed = models.Memory(
adventure_id=adventure.id, text="Kira is the innkeeper's daughter",
source_start=None, source_end=None,
)
typed.branch_id, typed.depth = ids["a"], 0
db.add(typed)
db.commit()
typed_id = typed.id
withdrawn = memorybank.forget_node(db, adventure, ids["nodes"]["A0"])
db.commit()
assert withdrawn == 0
assert db.get(models.Memory, typed_id) is not None
def test_a_summary_of_the_opening_node_is_still_withdrawn(forked):
"""The exception is about memories that describe nothing, not about depth 0.
A summary that genuinely ends on the opening node describes text that is
going, so it goes too — otherwise the root would collect exactly the
dangling rows `forget_node` replaced `prune_dangling_memories` to prevent.
"""
db, adventure, settings, ids = forked
derived = add_memory(db, adventure, "the opening, summarised", ids["nodes"]["A0"])
derived_id = derived.id
withdrawn = memorybank.forget_node(db, adventure, ids["nodes"]["A0"])
db.commit()
assert withdrawn == 1
assert db.get(models.Memory, derived_id) is None