diff --git a/servers/engine/lorebook.py b/servers/engine/lorebook.py index 1b33eddf..2fc7e8ae 100644 --- a/servers/engine/lorebook.py +++ b/servers/engine/lorebook.py @@ -87,10 +87,35 @@ def has_corpus(world_id: str) -> bool: return bool(_pages(world_id)) +# F10-1 — tier-0 noise floor. An authored (tier-0) page keeps absolute precedence over the wiki +# tier only if its FTS |rank| is at least this FRACTION of the best-matching page overall — i.e. +# it must match RELATIVE to the strongest hit this query produced. The floor is intentionally +# relative (not a fixed absolute epsilon): bm25 |rank| magnitudes are corpus-size-dependent (in a +# 2-page corpus every match ranks ≈ 2e-6; in the 356-page shipped corpus genuine matches rank +# ≈ 5.8), so an absolute cutoff would wrongly demote a genuine authored page in a tiny corpus. +# The fraction is small (0.01) so it ONLY catches near-zero noise: a pure-stopword match ranks +# ~6 ORDERS OF MAGNITUDE below a real match (the "the Counting House" repro: noise ≈ 2e-6 vs best +# ≈ 8.97 → ratio ~3e-7 ≪ 0.01, demoted), while a genuine-but-modest authored match (≈ 5.8, ratio +# ~0.65) is comfortably kept. best == 0 (every page matched every token) → floor 0 → no demotion +# → today's behavior. +_NOISE_FLOOR_FRACTION = 0.01 + + def _safe_match(query: str) -> str: - """OR-of-quoted-tokens (relevance, not all-terms-required) — same fix as recall.""" + """OR-of-quoted-tokens (relevance, not all-terms-required) — same fix as recall. + + F10-1: DROP sub-2-char tokens. A possessive query like ``"Wyrm's Crossing"`` tokenizes to + ``["Wyrm", "s", "Crossing"]``; the bare 1-char ``"s"`` matches the apostrophe-s of EVERY + page and drags unrelated pages above the dedicated one (the possessive-query failure class). + Single letters carry no retrieval signal, so we drop ``len < 2`` tokens — UNLESS that would + empty the match (a degenerate all-short query), in which case we keep them so the search + still runs rather than silently returning nothing. A query with no word-chars stays empty, + exactly as before.""" toks = re.findall(r"[A-Za-z0-9]+", query or "") - return " OR ".join(f'"{t}"' for t in toks) + kept = [t for t in toks if len(t) >= 2] + if not kept: + kept = toks # all tokens were short — don't empty the match; search them as-is + return " OR ".join(f'"{t}"' for t in kept) def _excerpt(text: str, tokens: list[str], width: int = 600) -> str: @@ -201,26 +226,48 @@ def lookup_lore( [(i, p["title"], p["text"], p["source"], p.get("tier", 1)) for i, p in enumerate(pages)], ) - def _match_tier(tier: int, n: int) -> list[int]: + def _match_tier(tier: int, n: int) -> list[tuple[int, float]]: # Filter on the UNINDEXED tier column alongside MATCH so authored matches # are found regardless of how many wiki pages also match (a bm25 over-fetch # over a 250-page corpus would otherwise bury the few short authored pages). + # Returns (rowid, rank) so the caller can apply the tier-0 NOISE FLOOR (F10-1): + # FTS `rank` is negative; a smaller |rank| ≈ a weaker match (≈0 == matched only a + # stopword), and we use |rank| to decide whether a tier-0 hit genuinely matches. try: - return [r[0] for r in conn.execute( - "SELECT rowid FROM lore WHERE lore MATCH ? AND tier = ? ORDER BY rank LIMIT ?", + return [(r[0], r[1]) for r in conn.execute( + "SELECT rowid, rank FROM lore WHERE lore MATCH ? AND tier = ? ORDER BY rank LIMIT ?", (match, tier, n), ).fetchall()] except sqlite3.OperationalError: return [] cap = max(limit, 1) - # Over-fetch each tier so the de-confliction can demote/drop contradicting hits - # and still fill `cap` with clean ones (without it, dropping a top hit would just - # shrink the result instead of promoting the next clean page). - fetch = cap * 3 if supersedes else cap - ids = _match_tier(0, fetch) + _match_tier(1, fetch) # authored canon first, then wiki to fill + # Over-fetch each tier so the de-confliction (and the F10-1 noise-floor demotion below) + # can drop/demote a hit and still fill `cap` with clean ones — without it, dropping a + # top hit would just shrink the result instead of promoting the next clean page. (The + # noise floor needs the over-fetch too: a demoted noise-rank tier-0 page must be able to + # be replaced by a genuinely-matching tier-1 page that the old `fetch=cap` never read.) + fetch = cap * 3 + t0 = _match_tier(0, fetch) # authored canon + t1 = _match_tier(1, fetch) # ingested wiki finally: conn.close() + + # F10-1 — tier-0 NOISE FLOOR (tighten the authored-canon precedence to GENUINE matches). + # The tier-0-first guarantee exists so a short authored page isn't bm25-buried by the + # 351-page wiki tier (the post-canon de-confliction guard). But a stopword-heavy query + # ("the Counting House") makes a few authored pages match at NOISE rank (≈0, on "the" + # alone), and the old absolute precedence let those noise matches fill the cap and bury the + # dedicated wiki page. We keep tier-0-FIRST only for authored pages whose |rank| clears a + # noise floor — within a bounded fraction of the best-matching page overall, or a tiny + # absolute epsilon — and DEMOTE the rest below the wiki tier (kept as a fallback, never + # dropped). A clean query (every tier-0 match is genuine) leaves `weak0` empty, so the order + # reduces to today's `t0 + t1` and the output is byte-identical. + best_overall = max((abs(r) for _, r in (t0 + t1)), default=0.0) + floor = _NOISE_FLOOR_FRACTION * best_overall + strong0 = [rid for rid, rank in t0 if abs(rank) >= floor] + weak0 = [rid for rid, rank in t0 if abs(rank) < floor] + ids = strong0 + [rid for rid, _ in t1] + weak0 # genuine authored, then wiki, then noise authored tokens = re.findall(r"[A-Za-z0-9]+", query or "") subs = [s.lower() for s in (supersedes or []) if str(s).strip()] diff --git a/servers/engine/store.py b/servers/engine/store.py index f7a9147b..13b695c7 100644 --- a/servers/engine/store.py +++ b/servers/engine/store.py @@ -180,6 +180,28 @@ def _slot_path(campaign_id: str, slot: str) -> Path: return _slots_dir(campaign_id) / f"{safe_path_segment(slot, 'slot')}.json" +def _slot_sessions_manifest_path(campaign_id: str, slot: str) -> Path: + """Path to a slot's SESSION-LOG manifest sidecar (F08-1). + + Lives under ``slots/.manifests/.json`` — a dedicated subdir so the non-recursive + ``slots/*.json`` glob in :func:`list_slots` never mistakes it for a restore point. The + manifest records each session file's byte length at slot time, so :func:`load_slot` can + roll the session logs back to match the rolled-back snapshot (a slot that restored only + snapshot.json left a discarded timeline — an undone TPK, a post-slot orphan session — + permanently canon in read_log_all / recap, the DM's lean-beat memory).""" + return _slots_dir(campaign_id) / ".manifests" / f"{safe_path_segment(slot, 'slot')}.json" + + +def _session_files(campaign_id: str) -> dict[str, Path]: + """The campaign's live session-log files: ``{filename: path}`` for ``sessions/*.jsonl`` + (non-recursive — the same glob shape read_log_all sees, so archived files under a + ``rolled-back-*`` subdir are invisible here too).""" + sessions_dir = _campaign_dir(campaign_id) / "sessions" + if not sessions_dir.is_dir(): + return {} + return {p.name: p for p in sessions_dir.glob("*.jsonl") if p.is_file()} + + def save_slot(campaign_id: str, slot: str = "quicksave") -> Path: """Copy a campaign's CURRENT live snapshot into a named save slot. @@ -187,16 +209,91 @@ def save_slot(campaign_id: str, slot: str = "quicksave") -> Path: the live snapshot (campaigns//slots/.json). The live snapshot.json is the unit of persistence the engine already maintains, so we copy IT verbatim (not a re-serialized model) — the slot is byte-for-byte the campaign as last saved. Raises ValueError if the campaign has - no live snapshot yet. Caller holds campaign_lock (sole-writer).""" + no live snapshot yet. Caller holds campaign_lock (sole-writer). + + F08-1: ALSO captures a SESSION-LOG manifest (each ``sessions/*.jsonl`` file's byte length at + slot time) into a sidecar, so a later load_slot can roll the append-only logs back to match + the rolled-back snapshot instead of leaving a discarded timeline canon in recap/lean-memory.""" live = _campaign_dir(campaign_id) / "snapshot.json" if not live.exists(): raise ValueError(f"no live snapshot for campaign {campaign_id!r} to save") data = live.read_text(encoding="utf-8") dest = _slot_path(campaign_id, slot) _atomic_write(dest, data) + # Capture the session-log state alongside the snapshot. The manifest maps each live session + # file name -> its current byte length; load_slot uses it to archive post-slot orphans and + # truncate grown sessions back to here. Best-effort: a manifest-write failure must not abort + # the slot save (the slot still restores the snapshot; logs degrade to today's behavior). + import json + try: + manifest = {name: p.stat().st_size for name, p in _session_files(campaign_id).items()} + _atomic_write(_slot_sessions_manifest_path(campaign_id, slot), json.dumps(manifest, indent=2)) + except OSError as exc: + log.warning("save_slot(%s,%s): could not write sessions manifest: %s", campaign_id, slot, exc) return dest +def _rollback_sessions_to_manifest(campaign_id: str, slot: str) -> None: + """Roll the campaign's session logs back to the state captured in a slot's manifest (F08-1). + + Called by :func:`load_slot` AFTER the snapshot is restored, under the caller's campaign_lock + (sole-writer). For every live ``sessions/*.jsonl`` file: + * NOT in the manifest (created after the slot) -> ARCHIVE the whole file (a post-slot + orphan timeline) under ``sessions/rolled-back-/``; + * LONGER than its manifest byte length (grown after the slot) -> archive the discarded + TAIL, then truncate the live file back to the manifested length; + * SHORTER than or equal to the manifest length -> LEAVE AS-IS (degrade, never pad/raise: + a shorter file can arise after an intervening restore of an older slot). + Archiving (not deleting) keeps the discarded timeline recoverable; the archive subdir is + invisible to read_log_all's non-recursive ``*.jsonl`` glob, so recap/lean-memory match the + rolled-back snapshot. A MANIFEST-LESS slot (legacy, pre-F08-1) is a no-op -> today's + behavior (snapshot restored, logs untouched).""" + import json + mpath = _slot_sessions_manifest_path(campaign_id, slot) + if not mpath.exists(): + return # legacy / manifest-less slot: degrade to today (leave session logs alone) + try: + manifest: dict = json.loads(mpath.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + # A corrupt manifest must not brick a restore — degrade to today (logs untouched). + log.warning("load_slot(%s,%s): unreadable sessions manifest, leaving logs as-is: %s", + campaign_id, slot, exc) + return + + live = _session_files(campaign_id) + if not live: + return + archive_dir = _campaign_dir(campaign_id) / "sessions" / f"rolled-back-{int(time.time() * 1000)}" + for name, path in live.items(): + try: + if name not in manifest: + # A whole session created after the slot — archive it out of the live set. + archive_dir.mkdir(parents=True, exist_ok=True) + os.replace(path, archive_dir / name) + continue + kept_len = int(manifest[name]) + cur_len = path.stat().st_size + if cur_len <= kept_len: + continue # unchanged or shorter — leave as-is (never pad/raise) + # Grown: archive the discarded tail, then truncate the live file back to slot length. + with open(path, "rb") as f: + head = f.read(kept_len) + tail = f.read() + archive_dir.mkdir(parents=True, exist_ok=True) + (archive_dir / name).write_bytes(tail) + # Atomic truncate via temp-then-replace so a crash never leaves a half-written log. + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "wb") as f: + f.write(head) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except OSError as exc: + # One unreadable/locked session file must not abort the whole rollback. + log.warning("load_slot(%s,%s): could not roll back session %s: %s", + campaign_id, slot, name, exc) + + def load_slot(campaign_id: str, slot: str = "quicksave") -> Campaign: """Restore a named save slot back over the live campaign snapshot. @@ -204,7 +301,11 @@ def load_slot(campaign_id: str, slot: str = "quicksave") -> Campaign: campaign it was saved from — we refuse to clobber the live state with a foreign/corrupt snapshot), then writes it to live via save_campaign (atomic + version-stamped). Raises FileNotFoundError if the slot is absent and ValueError if it is corrupt or mismatched. - OVERWRITES the live snapshot — caller holds campaign_lock and has confirmed intent.""" + OVERWRITES the live snapshot — caller holds campaign_lock and has confirmed intent. + + F08-1: ALSO rolls the append-only SESSION LOGS back to the slot's manifest, so a discarded + timeline (an undone TPK, a post-slot orphan session) can't stay canon in read_log_all / + recap (the DM's lean-beat memory). A manifest-less (legacy) slot leaves logs untouched.""" src = _slot_path(campaign_id, slot) if not src.exists(): raise FileNotFoundError(f"no save slot {slot!r} for campaign {campaign_id!r}") @@ -220,6 +321,8 @@ def load_slot(campaign_id: str, slot: str = "quicksave") -> Campaign: f"save slot {slot!r} belongs to campaign {c.id!r}, not {campaign_id!r}; refusing to restore" ) save_campaign(c) # atomic replace of the live snapshot.json + fresh version stamp + # Fence the session logs to the slot AFTER the snapshot lands (both under the caller's lock). + _rollback_sessions_to_manifest(campaign_id, slot) return c diff --git a/servers/engine/tests/test_lorebook.py b/servers/engine/tests/test_lorebook.py index 0f0f8b1d..b89090d4 100644 --- a/servers/engine/tests/test_lorebook.py +++ b/servers/engine/tests/test_lorebook.py @@ -224,6 +224,117 @@ def test_redact_superseded_helper_sentence_granularity(): assert did4 is True and gut4 is True and out4 == "[…superseded…]" +# --- F10-1: natural-query reachability (possessive token-drop + tier-0 noise floor) ---- +# The dedicated lore page must be reachable for (a) possessive queries — the 1-char "s" token +# of "Wyrm's Crossing" used to anchor unrelated pages — and (b) stopword-heavy queries — a few +# authored pages matched ONLY a stopword ("the") at noise rank yet took tier-0's absolute +# precedence and buried the genuinely-matching wiki page. The fix drops <2-char tokens and adds +# a NOISE FLOOR to the tier-0-first guarantee (an authored page leads only if it genuinely +# matches), WITHOUT regressing the authored-canon precedence for genuine matches. + +def test_safe_match_drops_one_char_tokens_but_never_empties(tmp_path, monkeypatch): + # Possessive: "Wyrm's Crossing" -> ["Wyrm","s","Crossing"]; the 1-char "s" matched 's' + # everywhere and dragged unrelated pages up. Drop sub-2-char tokens. + assert lorebook._safe_match("Wyrm's Crossing") == '"Wyrm" OR "Crossing"' + assert lorebook._safe_match("the Counting House") == '"the" OR "Counting" OR "House"' + # ...unless dropping would EMPTY the match (a query of only short tokens) — then keep them, + # so a degenerate query still searches rather than silently returning nothing. + assert lorebook._safe_match("a I") == '"a" OR "I"' + assert lorebook._safe_match("x") == '"x"' + assert lorebook._safe_match("") == "" # no tokens at all -> empty (unchanged) + + +def test_possessive_query_reaches_dedicated_page(tmp_path, monkeypatch): + # A possessive natural query must reach the page whose slug IS that place — the 1-char "s" + # token previously dragged unrelated 's'-bearing pages up and buried it. Enough decoy pages + # contain a bare "s" word that, with the "s" token live, they crowd the dedicated page out of + # the top-`limit`; dropping the 1-char token surfaces it. + lore = _world(tmp_path, monkeypatch) + (lore / "wiki" / "wyrm-s-crossing.md").write_text( + "# Wyrm's Crossing\nWyrm's Crossing is the great bridge district spanning the river.\n", + encoding="utf-8", + ) + # Decoys: tier-0 authored pages that match ONLY the bare 1-char "s" token (no Wyrm/Crossing). + # With the "s" token live they out-rank the dedicated wiki page (tier-0 precedence) and fill + # the small cap; with it dropped they no longer match at all. + for i in range(4): + (lore / f"decoy{i}.md").write_text( + f"# Decoy {i}\nThe letter s s s appears s often here, s s, but nothing else.\n", + encoding="utf-8", + ) + hits = lorebook.lookup_lore("tw", "Wyrm's Crossing", 3) + assert any("wyrm-s-crossing" in h["source"] for h in hits), \ + "possessive query must reach the dedicated wiki page (1-char 's' token must not bury it)" + + +def test_stopword_query_does_not_let_noise_authored_pages_bury_real_page(tmp_path, monkeypatch): + # Stopword-heavy query: authored pages that match ONLY the stopword ("the") at noise rank + # must NOT take tier-0 absolute precedence and bury the genuinely-matching wiki page. Enough + # noise-rank authored decoys exist to fill the small cap under the old tier-0-first rule. + lore = _world(tmp_path, monkeypatch) + # Authored decoys that contain ONLY the stopword "the" (no Counting / House) — they match at + # noise rank yet under the old rule took tier-0 absolute precedence and filled the cap. + for i in range(4): + (lore / f"legend{i}.md").write_text( + f"# Legend {i}\nThe heroes and the deeds and the days of the realm number {i}.\n", + encoding="utf-8", + ) + # The genuinely-matching page (the actual subject) lives in the wiki tier. + (lore / "wiki" / "counting-house.md").write_text( + "# The Counting House\nThe Counting House is the great bank of the Lower City, " + "where the Counting House clerks weigh every coin in the House vaults.\n", + encoding="utf-8", + ) + hits = lorebook.lookup_lore("tw", "the Counting House", 3) + assert any("counting-house" in h["source"] for h in hits), \ + "a noise-rank stopword match in tier-0 must not bury the genuinely-matching wiki page" + + +def test_authored_canon_still_wins_a_GENUINE_tie(tmp_path, monkeypatch): + # The noise floor only demotes pages that match at NOISE rank. A tier-0 page that GENUINELY + # matches the query must STILL out-rank a tier-1 page (the post-canon de-confliction guard). + lore = _world(tmp_path, monkeypatch) + (lore / "gortash.md").write_text( + "# Gortash\n*Era: 1492 DR*\nEnver Gortash is dead, slain in the Battle of Baldur's Gate.\n", + encoding="utf-8", + ) + (lore / "wiki" / "gortash.md").write_text( + "# Gortash\n" + "Enver Gortash is the living Archduke of Baldur's Gate. " * 60, + encoding="utf-8", + ) + hits = lorebook.lookup_lore("tw", "Gortash Archduke", 3) + assert hits and hits[0]["title"] == "Gortash" and "dead" in hits[0]["excerpt"].lower(), \ + "a genuinely-matching authored page must still beat the stale wiki page" + + +def test_possessive_and_stopword_reach_real_corpus_dedicated_pages(): + # The shipped corpus repros from the audit (F10-1): both query classes must reach the + # dedicated page. (Guards against a regression on the real 356-page baldurs-gate corpus.) + possessive = lorebook.lookup_lore("baldurs-gate", "Wyrm's Crossing", 5) + assert any("wyrm-s-crossing" in h["source"] for h in possessive), \ + "real-corpus possessive query must reach wyrm-s-crossing.md" + stopword = lorebook.lookup_lore("baldurs-gate", "the Counting House", 5) + assert any("counting-house" in h["source"] for h in stopword), \ + "real-corpus stopword query must reach counting-house-baldur-s-gate.md" + + +def test_clean_query_output_byte_identical_to_pre_fix_ordering(): + # ADDITIVE guarantee: when no 1-char/stopword-noise is involved, every authored match clears + # the noise floor (weak0 is empty), so ordering reduces to today's tier-0-then-tier-1 — the + # output is byte-identical to before the fix. Pinned for a battery of clean queries. + pinned = { + "Gortash Archduke": ["the-absolute-and-the-dead-three.md", "baldurs-gate.md", + "factions.md", "the-legends.md", "council-of-four.md"], + "Flaming Fist": ["factions.md", "baldurs-gate.md", "flaming-fist.md", + "ulder-ravengard.md", "wyrm-s-rock.md"], + "Steel Watch": ["the-absolute-and-the-dead-three.md", "baldurs-gate.md", + "factions.md", "watch-citadel.md", "guthmere.md"], + } + for q, expected in pinned.items(): + got = [h["source"] for h in lorebook.lookup_lore("baldurs-gate", q, 5)] + assert got == expected, f"clean query {q!r} must be byte-identical: {got!r} != {expected!r}" + + def test_legends_page_covers_all_eleven_shipped_heroes(): # S6 audit (content gap): the authored hero roster the-legends.md must name ALL 11 major # heroes so lookup_lore("Gale"/"Halsin") resolves to the authored bio page instead of diff --git a/servers/engine/tests/test_slots.py b/servers/engine/tests/test_slots.py index 4795aa46..dc9e2318 100644 --- a/servers/engine/tests/test_slots.py +++ b/servers/engine/tests/test_slots.py @@ -156,3 +156,139 @@ def test_save_slot_is_engine_sha_stamped_on_load(tmp_path, monkeypatch, cid): assert restored.engine_sha == store.engine_sha() on_disk = json.loads((store._campaign_dir(cid) / "snapshot.json").read_text(encoding="utf-8")) assert "engine_sha" in on_disk + + +# --------------------------------------------------------------------------- +# F08-1 — load_slot must roll back the SESSION LOGS too, not just the snapshot. +# A slot that restores only snapshot.json leaves a discarded timeline (an undone TPK, a +# post-slot orphan session) permanently canon in read_log_all / recap (the DM's lean-beat +# memory). save_slot captures a byte-length manifest of the session logs; load_slot archives +# post-slot orphans and truncates grown sessions back to their slot-time length (archiving the +# discarded tail), so the recap/lean surface matches the rolled-back snapshot. +# --------------------------------------------------------------------------- + +from models import SessionLogEntry + + +def _entry(text, t): + return SessionLogEntry(kind="narration", text=text, t=t) + + +def test_load_slot_truncates_grown_session_to_slot_length(tmp_path, monkeypatch, cid): + sid = "sess-1" + store.append_log(cid, sid, _entry("The party enters the crypt.", 1.0)) + store.save_slot(cid, "quicksave") # snapshot the log state too + # The discarded timeline appended to the SAME session after the slot. + store.append_log(cid, sid, _entry("The party is slaughtered. TPK.", 2.0)) + store.load_slot(cid, "quicksave") + texts = [e.text for e in store.read_log(cid, sid)] + assert texts == ["The party enters the crypt."], "post-slot TPK must be rolled back out of the log" + # read_log_all (the DM's lean-memory / recap surface) no longer replays the discarded beat. + seen = [e.text for e in store.read_log_all(cid, [sid])] + assert not any("TPK" in t for t in seen) + + +def test_load_slot_archives_orphan_post_slot_session(tmp_path, monkeypatch, cid): + sid = "sess-1" + store.append_log(cid, sid, _entry("The party enters the crypt.", 1.0)) + store.save_slot(cid, "quicksave") + # A whole NEW session file created after the slot — an orphan timeline. + store.append_log(cid, "sess-2-orphan", _entry("Ghosts wander the ruined party.", 3.0)) + store.load_slot(cid, "quicksave") + sessions_dir = store._campaign_dir(cid) / "sessions" + # The orphan file is gone from the live (glob-visible) session set... + live = {p.stem for p in sessions_dir.glob("*.jsonl")} + assert "sess-2-orphan" not in live and "sess-1" in live + # ...and read_log_all never replays it. + seen = [e.text for e in store.read_log_all(cid, [sid])] + assert not any("Ghosts" in t for t in seen) + # The discarded timeline is ARCHIVED (recoverable), not destroyed — under a rolled-back-/ + # subdir that the non-recursive *.jsonl glob can't see. + archives = list(sessions_dir.glob("rolled-back-*")) + assert archives and any(a.is_dir() for a in archives) + archived_orphan = list(sessions_dir.rglob("sess-2-orphan.jsonl")) + assert archived_orphan, "the orphan session must be archived, not deleted" + + +def test_load_slot_archives_grown_session_tail(tmp_path, monkeypatch, cid): + sid = "sess-1" + store.append_log(cid, sid, _entry("kept beat", 1.0)) + store.save_slot(cid, "quicksave") + store.append_log(cid, sid, _entry("discarded beat", 2.0)) + store.load_slot(cid, "quicksave") + sessions_dir = store._campaign_dir(cid) / "sessions" + # The truncated tail is archived under rolled-back-/, so the undone beat is recoverable. + archived = list(sessions_dir.rglob("sess-1.jsonl")) + archived = [p for p in archived if "rolled-back-" in str(p)] + assert archived, "the discarded session tail must be archived" + assert any("discarded beat" in p.read_text(encoding="utf-8") for p in archived) + + +def test_load_slot_manifestless_slot_degrades_to_today(tmp_path, monkeypatch, cid): + # A slot written by an OLD engine (no sessions manifest sidecar) must behave EXACTLY as + # today: restore the snapshot and leave the session logs untouched (no archiving, no raise). + sid = "sess-1" + store.append_log(cid, sid, _entry("beat one", 1.0)) + store.save_slot(cid, "quicksave") + # Simulate a manifest-less (legacy) slot by deleting the manifest sidecar. + mpath = store._slot_sessions_manifest_path(cid, "quicksave") + if mpath.exists(): + mpath.unlink() + store.append_log(cid, sid, _entry("beat two (kept under legacy degrade)", 2.0)) + store.load_slot(cid, "quicksave") + texts = [e.text for e in store.read_log(cid, sid)] + assert texts == ["beat one", "beat two (kept under legacy degrade)"], \ + "a manifest-less slot must not touch the session logs (degrade to today's behavior)" + + +def test_load_slot_shorter_than_manifest_degrades_leave_as_is(tmp_path, monkeypatch, cid): + # After an intervening restore of an OLDER slot a session file can be SHORTER than a later + # slot's manifest length. Truncation must degrade to leave-as-is — never pad, never raise. + sid = "sess-1" + store.append_log(cid, sid, _entry("beat one", 1.0)) + store.append_log(cid, sid, _entry("beat two", 2.0)) + store.save_slot(cid, "long") # manifest records the 2-beat length + # Now make the live session SHORTER than the manifest (e.g. an older-slot restore happened). + spath = store._campaign_dir(cid) / "sessions" / f"{sid}.jsonl" + spath.write_text((_entry("beat one", 1.0)).model_dump_json() + "\n", encoding="utf-8") + # Restoring the longer slot must NOT pad or raise — leave the shorter file as-is. + store.load_slot(cid, "long") + texts = [e.text for e in store.read_log(cid, sid)] + assert texts == ["beat one"], "a session shorter than the manifest must be left as-is, not padded/raised" + + +def test_load_slot_snapshot_restore_still_byte_identical(tmp_path, monkeypatch, cid): + # The session-log fencing must not disturb the snapshot restore: live snapshot after a + # load_slot is still freshly stamped from the slot's campaign (the prior behavior). + store.save_slot(cid, "quicksave") + c = store.load_campaign(cid) + c.day = 7 + store.save_campaign(c) + restored = store.load_slot(cid, "quicksave") + assert restored.day == 1 + assert store.load_campaign(cid).day == 1 + + +def test_save_slot_sessions_manifest_not_listed_as_a_slot(tmp_path, monkeypatch, cid): + # The manifest sidecar must be invisible to list_slots (it is NOT a restore point). + store.append_log(cid, "sess-1", _entry("beat", 1.0)) + store.save_slot(cid, "quicksave") + assert {s["slot"] for s in store.list_slots(cid)} == {"quicksave"} + + +def test_load_slot_no_sessions_dir_is_safe(tmp_path, monkeypatch, cid): + # A campaign that never logged a session (no sessions/ dir) must load_slot without error. + store.save_slot(cid, "quicksave") + store.load_slot(cid, "quicksave") # must not raise + assert store.load_campaign(cid).id == cid + + +def test_tool_load_slot_rolls_back_logs(cid): + # End-to-end through the MCP tool wrappers (server.save_slot / server.load_slot). + server.start_session(cid, "opening") + store.append_log(cid, store.load_campaign(cid).active_session_id, _entry("pre-slot beat", 1.0)) + server.save_slot(cid, "quicksave") + store.append_log(cid, store.load_campaign(cid).active_session_id, _entry("post-slot TPK beat", 2.0)) + server.load_slot(cid, "quicksave") + seen = [e.text for e in store.read_log_all(cid, store.load_campaign(cid).session_ids)] + assert not any("post-slot TPK" in t for t in seen)