Give every memory a node, and show only the ones on your path
A hand-written memory used to carry a NULL depth, described in the model as "belongs to the adventure rather than to a path". That sounds harmless and is not: a NULL is a coordinate no fork can cap, so a note typed on one line followed the reader onto branches whose story it never described. It takes the head now — the story you were reading when you wrote it — and obeys exactly the rule a summarised memory obeys. The unanchored escape clause in lineage.Path.clause existed for that single case and is deleted rather than left unused. Its docstring argued that a capped depth would drop a typed memory the moment its branch stopped being the newest entry; anchoring answers the same worry better, because the memory is not exempt from the path, it is on one. The drawer now shows the path being read and nothing else, filtered by the clause retrieval itself uses, so the bank you can see is the bank the model can see. Nothing is stranded: a memory lives on a branch, switching to that branch shows it, and deleting the branch deletes it. Pinning decides order, the path decides existence. Migration 62 lands existing NULL-depth memories at depth 0 of their branch rather than at the tip. 0 is at or before every fork point, so every memory stays visible from exactly the paths it is visible from today — nobody's bank loses a row on deploy. The tip is the tidier-sounding choice and would have emptied them out of every branch forked earlier than they were typed. This supersedes the on_path flag and the "another branch" badge from earlier today; anchoring makes them redundant, and they are removed. Four tests changed because they asserted the old contract, not because they broke. The one worth reading is the pair replacing test_a_hand_written_memory_is_not_lost_at_the_first_fork: typed on shared trunk it still survives a fork, and typed on ground the fork never travelled it no longer follows you. 402 tests. Verified on tools/branch_fixture.py: each branch's drawer holds its own memory and not the other's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
1d1367ce3a
commit
2d38a162d4
@@ -443,7 +443,7 @@ def test_a_memory_on_the_line_left_behind_is_out_of_range_on_the_fork(client):
|
||||
path = lineage.path_of(db, adventure)
|
||||
visible = db.query(models.Memory).filter(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
path.clause(models.Memory, unanchored=True),
|
||||
path.clause(models.Memory),
|
||||
).all()
|
||||
assert visible == [], "a sibling's memory reached this branch"
|
||||
# ...and the mark reads as one depth short of it, so the block is due
|
||||
|
||||
@@ -361,38 +361,50 @@ def _add_memory(client, text):
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_the_drawer_keeps_every_memory_but_says_which_are_off_the_path(client):
|
||||
"""Both halves of the split, in one test, because either alone is a bug.
|
||||
def test_a_hand_written_memory_is_anchored_where_it_was_written(client):
|
||||
"""It takes the head, so it is a memory *of a story* rather than of an
|
||||
adventure. A NULL depth is a coordinate no fork can cap."""
|
||||
root, forked = _forked(client)
|
||||
memory_id = _add_memory(client, "Took the other door.")
|
||||
|
||||
Listing only the path's memories would leave the rest impossible to find
|
||||
and delete, in a phase whose rule is that nothing is removed automatically.
|
||||
Listing them all *alike* would tell the player the model remembers
|
||||
something that is never retrieved on this branch.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memory = db.get(models.Memory, memory_id)
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
assert memory.branch_id == forked, "the branch being read"
|
||||
assert memory.depth is not None, "never NULL again"
|
||||
assert memory.depth == adventure.head_depth
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_the_drawer_shows_the_path_being_read_and_nothing_else(client):
|
||||
"""The bank you can see is the bank the model can see.
|
||||
|
||||
An adventure-wide list would show memories from branches this story never
|
||||
went down — which are never retrieved — and a reader cannot tell those from
|
||||
the ones actually in play.
|
||||
"""
|
||||
root, forked = _forked(client)
|
||||
on_the_fork = _add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
on_the_root = _add_memory(client, "Went the long way instead.")
|
||||
|
||||
listed = {m["id"]: m for m in _memories(client)}
|
||||
assert set(listed) == {on_the_fork, on_the_root}, "the whole bank, always"
|
||||
assert listed[on_the_root]["on_path"] is True
|
||||
assert listed[on_the_fork]["on_path"] is False, "written on a branch we left"
|
||||
assert {m["id"] for m in _memories(client)} == {on_the_root}, \
|
||||
"the fork's memory is not on this story"
|
||||
|
||||
# And it follows the reader rather than being a property of the memory —
|
||||
# but **asymmetrically**, which is the part worth pinning. A fork borrows
|
||||
# its parent's story, so a memory written on the parent is on the fork's
|
||||
# path too. The reverse is not true: the parent never went down the fork.
|
||||
# Switching to the fork shows its own memory — and the root's, because a
|
||||
# fork borrows its ancestors up to the point it left them. The relationship
|
||||
# is asymmetric on purpose; the parent never went down the fork.
|
||||
_switch(client, forked)
|
||||
listed = {m["id"]: m for m in _memories(client)}
|
||||
assert listed[on_the_fork]["on_path"] is True
|
||||
assert listed[on_the_root]["on_path"] is True, "an ancestor's memory is shared"
|
||||
listed = {m["id"] for m in _memories(client)}
|
||||
assert on_the_fork in listed
|
||||
assert on_the_root not in listed, "written after the fork left this branch"
|
||||
|
||||
|
||||
def test_the_off_path_flag_agrees_with_what_retrieval_can_see(client):
|
||||
"""The flag has to come from retrieval's own predicate, not a second
|
||||
spelling of it. Two spellings would drift, and the failure mode is a badge
|
||||
claiming the opposite of what the model is actually given."""
|
||||
def test_the_drawer_and_retrieval_agree_on_what_is_visible(client):
|
||||
"""One predicate, so a memory can never be listed but unretrievable (or the
|
||||
reverse). Two spellings of "on this path" would eventually drift."""
|
||||
root, forked = _forked(client)
|
||||
_add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
@@ -401,32 +413,42 @@ def test_the_off_path_flag_agrees_with_what_retrieval_can_see(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
visible = {
|
||||
retrievable = {
|
||||
row[0] for row in db.execute(
|
||||
select(models.Memory.id).where(
|
||||
models.Memory.adventure_id == adventure.id,
|
||||
lineage.path_of(db, adventure).clause(
|
||||
models.Memory, unanchored=True
|
||||
),
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
)
|
||||
)
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
flagged = {m["id"] for m in _memories(client) if m["on_path"]}
|
||||
assert flagged == visible
|
||||
assert {m["id"] for m in _memories(client)} == retrievable
|
||||
|
||||
|
||||
def test_a_memory_from_a_deleted_branch_is_gone_from_the_drawer(client):
|
||||
"""Not merely off-path — the row goes with the branch, through the cascade."""
|
||||
def test_deleting_a_branch_deletes_the_memories_written_on_it(client):
|
||||
"""Not merely out of view — the row goes with the branch, through the
|
||||
cascade. That is what keeps "the drawer shows only your path" from
|
||||
stranding anything: a memory you cannot see is on a branch you can still
|
||||
switch to, and deleting that branch takes it for good."""
|
||||
root, forked = _forked(client)
|
||||
doomed = _add_memory(client, "Took the other door.")
|
||||
_switch(client, root)
|
||||
assert doomed in {m["id"] for m in _memories(client)}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(models.Memory, doomed) is not None, "still on its own branch"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
assert _delete(client, forked).status_code == 204
|
||||
assert doomed not in {m["id"] for m in _memories(client)}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(models.Memory, doomed) is None, "gone with the branch"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- backup
|
||||
|
||||
@@ -212,18 +212,50 @@ def test_the_lineage_is_read_whole_not_windowed(forked):
|
||||
assert "on the shared trunk" in retrieved(adventure, settings)
|
||||
|
||||
|
||||
def test_a_hand_written_memory_is_not_lost_at_the_first_fork(forked):
|
||||
"""A memory nobody derived summarises no node, so it has a branch but no
|
||||
depth. A capped `depth <= fork` would drop it the moment its branch stopped
|
||||
being the newest entry — a memory vanishing some turns after it was typed,
|
||||
which is exactly the kind of thing nothing reports."""
|
||||
def test_a_hand_written_memory_is_anchored_where_it_was_typed(forked):
|
||||
"""SP7: a typed memory takes the head, so it obeys the same rule as a
|
||||
summarised one.
|
||||
|
||||
It used to carry no depth, which sounded like "belongs to the whole
|
||||
adventure" and behaved like "cannot be capped at a fork" — it followed the
|
||||
reader onto branches whose story it never described. Anchoring it makes the
|
||||
bank answer one question rather than two.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
typed = add_memory(db, adventure, "typed by hand", None)
|
||||
assert (typed.branch_id, typed.depth) == (ids["a"], None)
|
||||
assert (typed.branch_id, typed.depth) == (ids["a"], 5), "the head it was typed at"
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7) # fork away from where it was written
|
||||
assert "typed by hand" in retrieved(adventure, settings)
|
||||
|
||||
def test_a_typed_memory_survives_a_fork_of_the_ground_it_was_typed_on(forked):
|
||||
"""The half of the old behaviour that was right, kept.
|
||||
|
||||
Typed on the shared trunk it is still there after forking away — but
|
||||
because the fork's path goes through that node, not because the memory was
|
||||
exempt from being capped.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 3) # the trunk B, and so C, branch from
|
||||
add_memory(db, adventure, "typed on the trunk", None)
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
assert "typed on the trunk" in retrieved(adventure, settings)
|
||||
|
||||
|
||||
def test_a_typed_memory_does_not_follow_you_onto_a_path_it_is_not_on(forked):
|
||||
"""And the half that was wrong, fixed.
|
||||
|
||||
A5 is A's own continuation past the point B left it, so it is a sibling of
|
||||
the story C tells — precisely where the `sibling` memory sits, and excluded
|
||||
for precisely the same reason. Typing rather than summarising buys no
|
||||
exemption from the path.
|
||||
"""
|
||||
db, adventure, settings, ids = forked
|
||||
switch_to(db, adventure, ids["a"], 5)
|
||||
add_memory(db, adventure, "typed off the path", None)
|
||||
|
||||
switch_to(db, adventure, ids["c"], 7)
|
||||
assert "typed off the path" not in retrieved(adventure, settings)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the marks
|
||||
|
||||
@@ -276,10 +276,12 @@ def test_memories_attach_to_the_node_they_summarised(pre_tree):
|
||||
assert depth == source_end, "the memory hangs off the last action it covered"
|
||||
assert branch_id is not None
|
||||
|
||||
# A hand-written memory has no node: it gets a branch, but no depth, which
|
||||
# SP3 reads as belonging to the adventure rather than to a path.
|
||||
# A hand-written memory summarised no node, so SP7's migration 62 lands it
|
||||
# at depth 0 of its branch. 0 is at or before every fork point, so it stays
|
||||
# visible from exactly the paths it was visible from before — anchoring
|
||||
# takes nothing out of anybody's existing bank.
|
||||
manual = rows("SELECT depth, branch_id FROM memories WHERE source_end IS NULL")
|
||||
assert manual and all(depth is None and branch is not None for depth, branch in manual)
|
||||
assert manual and all(depth == 0 and branch is not None for depth, branch in manual)
|
||||
|
||||
|
||||
def test_the_cursors_become_the_nodes_they_named(pre_tree):
|
||||
@@ -439,7 +441,13 @@ def test_a_blank_adventure_has_a_branch_before_anything_is_played(client):
|
||||
db.close()
|
||||
|
||||
|
||||
def test_a_hand_written_memory_gets_a_branch_but_no_depth(client):
|
||||
def test_a_hand_written_memory_is_anchored_at_the_head(client):
|
||||
"""SP7: nothing carries a NULL depth any more.
|
||||
|
||||
On an adventure with no story yet the head is NO_DEPTH (-1), which reads as
|
||||
"before the first node" and so is in range of every branch — right for a
|
||||
note written before anything has happened.
|
||||
"""
|
||||
adventure_id = client.post("/api/adventures", json={}).json()["id"]
|
||||
|
||||
created = client.post(
|
||||
@@ -450,8 +458,9 @@ def test_a_hand_written_memory_gets_a_branch_but_no_depth(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
memory = db.query(models.Memory).filter_by(adventure_id=adventure_id).one()
|
||||
adventure = db.get(models.Adventure, adventure_id)
|
||||
assert memory.branch_id is not None
|
||||
assert memory.depth is None
|
||||
assert memory.depth == adventure.head_depth == -1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user