"""M9: every older bundle still imports, and none is reinterpreted. A backup that stops importing is not a backup, so the importer keeps every version it has ever written. That is the easy half. The hard half is the rule `V1-ACCEPTANCE-TESTS.md` I07 states about the head and this file generalises: > Do not reinterpret missing legacy data using modern assumptions that did not > exist when the file was written. An older file is missing things because its **format** could not carry them, not because the campaign lacked them, and the two demand opposite treatment. A file written before the head was carried opens at its tip, because tip was the only position that format could represent — reproducing what it recorded. A file written before state events existed opens with no state events, because manufacturing an audit trail from the snapshots it does carry would be this build's reading of a history it never saw, handed to a reader as the record of what happened. Each seam below is built by taking a real v3 export and removing exactly what the older format could not hold. That is deliberate: a checked-in fixture file drifts, and a hand-written one tests a shape nothing ever wrote. python -m pytest tests/test_m9_legacy_bundles.py -v """ import copy import pytest from fastapi import Depends from fastapi.testclient import TestClient from app import auth, bundle, limits, memorybank, models from app.database import Base, SessionLocal, engine, get_db from app.knowledge import embeddings from app.main import app from app.routers import adventures import m9_fixture from fakes import ScriptedProvider from test_m9_portability import StubDerived @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) memorybank._vector_cache.clear() embeddings._cache.clear() setup = SessionLocal() user = models.User(is_guest=False, email="legacy@example.com") setup.add(user) setup.flush() setup.add(models.Settings( user_id=user.id, model="test-model", embedding_model="stub-embed", context_token_budget=4000, max_output_tokens=400, )) adventure = models.Adventure( user_id=user.id, title="Source", campaign_canon=m9_fixture.CAMPAIGN_CANON, ) setup.add(adventure) setup.flush() setup.add(models.Action( adventure_id=adventure.id, type="start", text=m9_fixture.OPENING, )) setup.commit() adv_id, user_id = adventure.id, user.id setup.close() monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived()) monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived()) app.dependency_overrides[auth.get_current_user] = ( lambda db=Depends(get_db): db.get(models.User, user_id) ) test_client = TestClient(app) test_client.adv_id = adv_id try: yield test_client finally: app.dependency_overrides.clear() adventures.turns._active_turns.clear() memorybank._vector_cache.clear() embeddings._cache.clear() Base.metadata.drop_all(bind=engine) @pytest.fixture() def current(client): """A real v3 export of the M9 fixture, to age backwards from.""" m9_fixture.build(client, client.adv_id) response = client.get(f"/api/adventures/{client.adv_id}/export") assert response.status_code == 200 return response.json() # ---------------------------------------------------- ageing a bundle backwards def as_of(payload: dict, era: str) -> dict: """The same campaign as an export from an earlier era. Each step removes only what that era's format genuinely could not carry, so the result is the file a build of that vintage would have produced from this campaign — not a mutilated modern one. """ older = copy.deepcopy(payload) eras = ("pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head") assert era in eras, era reached = eras.index(era) # M9 (v3): the evidence sections and the node identities. older["format"] = bundle.TREE_FORMAT for key in ("stateEvents", "stateProposals", "summaries"): older.pop(key, None) for action in older["actions"]: for key in ("contextSnapshot", "contextSnapshotZ", "id", "parentId"): action.pop(key, None) for memory in older.get("memories") or []: memory.pop("authority", None) for source in older.get("knowledge") or []: for key in ("sourceId", "parserVersion", "chunkingVersion"): source.pop(key, None) if reached == 0: return older # M7: the imported knowledge library. older.pop("knowledge", None) if reached == 1: return older # M5: the authoritative narrative state, its per-position snapshots, and # the campaign's own canon. for key in ("narrativeState", "campaignCanon"): older.pop(key, None) for action in older["actions"]: for key in ("narrativeStateAfter", "stateChanges"): action.pop(key, None) if reached == 2: return older # M4: named Save Points. older.pop("checkpoints", None) if reached == 3: return older # M3: the chosen head. Such a file could only ever be read at its tip. older.pop("headDepth", None) return older def bring_back(client, payload) -> int: response = client.post("/api/adventures/import", json=payload) assert response.status_code == 201, response.text[:500] return response.json()["id"] def _rows(adv_id, model) -> int: with SessionLocal() as db: return db.query(model).filter(model.adventure_id == adv_id).count() def _tree_size(client, adv_id) -> int: """Every retained row, which is what "no accepted story was lost" means.""" return len(client.get(f"/api/adventures/{adv_id}/export").json()["actions"]) # --------------------------------------------------------------- every era @pytest.mark.parametrize("era", [ "pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head", ]) def test_no_accepted_story_is_lost_at_any_seam(client, current, era): """The floor under every case below: the turns all arrive. Counted over the whole retained tree rather than the active path, because the head moves between eras and a count of what is on screen would move with it. """ copy_id = bring_back(client, as_of(current, era)) assert _tree_size(client, copy_id) == len(current["actions"]) @pytest.mark.parametrize("era", [ "pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head", ]) def test_nothing_is_invented_to_fill_a_gap_the_format_left(client, current, era): """Absent means the format could not say. It never means "make one up". Each era is checked against what that era's files could hold: a pre-M9 file gets no audit trail and no summaries, a pre-M7 file no knowledge, a pre-M5 file no state, a pre-Save-Point file no Save Points. """ copy_id = bring_back(client, as_of(current, era)) reached = ("pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head").index(era) assert _rows(copy_id, models.StateEvent) == 0 assert _rows(copy_id, models.StateProposal) == 0 assert _rows(copy_id, models.Summary) == 0 if reached >= 1: assert _rows(copy_id, models.KnowledgeSource) == 0 assert _rows(copy_id, models.KnowledgeChunk) == 0 if reached >= 2: state = client.get(f"/api/adventures/{copy_id}/state").json() assert state["document"]["facts"] == [] assert state["document"]["entities"] == {} assert client.get(f"/api/adventures/{copy_id}").json()["canon_rules"] == [] if reached >= 3: assert _rows(copy_id, models.Checkpoint) == 0 # ------------------------------------------------------ the head, era by era def test_a_pre_m9_file_still_opens_at_the_head_it_recorded(client, current): """v2 carried the head, so it is honoured exactly as before.""" copy_id = bring_back(client, as_of(current, "pre-m9")) with SessionLocal() as db: assert db.get(models.Adventure, copy_id).head_depth == current["headDepth"] def test_a_pre_active_head_file_opens_at_its_tip(client, current): """I07's compatibility clause. Not a degraded path. Such a file was written when the head could not be anywhere but the tip, so opening it there reproduces the position it recorded. An import that refused it, or that guessed some other position, would be the failure. """ copy_id = bring_back(client, as_of(current, "pre-active-head")) with SessionLocal() as db: adventure = db.get(models.Adventure, copy_id) tip = max( row.depth for row in db.query(models.Action).filter( models.Action.adventure_id == copy_id, models.Action.branch_id == adventure.head_branch_id, ) ) assert adventure.head_depth == tip assert adventure.head_depth > current["headDepth"], ( "the fixture's head must really be behind its tip, or this proves nothing" ) def test_a_pre_active_head_file_offers_no_redo_because_it_is_at_the_tip( client, current ): copy_id = bring_back(client, as_of(current, "pre-active-head")) page = client.get(f"/api/adventures/{copy_id}").json() assert page["can_redo"] is False assert page["can_undo"] is True # ----------------------------------------------------- what each era can do def test_a_pre_m5_campaign_can_be_played_on_and_gains_state_from_there( client, current ): """The M5 rule, applied to an import: no backfill, and no obstacle either. An old campaign starts with an empty state because its narration was never read by a state extractor. The next turn fills it in, which is what makes "no backfill" a decision rather than a loss. """ copy_id = bring_back(client, as_of(current, "pre-m5")) assert client.get(f"/api/adventures/{copy_id}/state").json()["empty"] is True ScriptedProvider.replies = [ "The door gives at last.\n" + __import__("fakes").state_block([ {"type": "add_fact", "predicate": "tally", "value": 500, "fact_id": "tally-500"} ]) ] played = client.post(f"/api/adventures/{copy_id}/actions", json={"type": "do", "text": "push harder"}) assert played.status_code == 200, played.text[:300] after = client.get(f"/api/adventures/{copy_id}/state").json() assert after["empty"] is False assert any(f["predicate"] == "tally" for f in after["document"]["facts"]) def test_a_pre_m7_campaign_needs_no_source_and_can_import_one(client, current): copy_id = bring_back(client, as_of(current, "pre-m7")) assert client.get(f"/api/adventures/{copy_id}/knowledge").json() == [] # It plays without one. assert client.get(f"/api/adventures/{copy_id}/context").status_code == 200 # And gains one. landed = m9_fixture.upload( client, copy_id, "canon.md", m9_fixture.CANON_MD, "canon", ) library = client.get(f"/api/adventures/{copy_id}/knowledge").json() assert [s["id"] for s in library] == [landed] assert library[0]["index_state"] == "ready" def test_a_pre_save_point_campaign_can_be_given_one(client, current): copy_id = bring_back(client, as_of(current, "pre-save-points")) assert client.get(f"/api/adventures/{copy_id}/checkpoints").json() == [] made = client.post(f"/api/adventures/{copy_id}/checkpoints", json={"name": "From here", "note": ""}) assert made.status_code == 201, made.text[:300] assert made.json()["resolved"] is True def test_a_pre_m9_campaign_re_exports_as_v3_without_gaining_evidence( client, current ): """Re-exporting an old campaign does not turn absence into presence. The file it writes is a v3 file, because that is what this build writes. Its evidence sections are empty, because the campaign genuinely has none — and a later reader can therefore trust a v3 file's empty `stateEvents` to mean "this campaign has no audit trail" rather than "the file could not say". """ copy_id = bring_back(client, as_of(current, "pre-m9")) again = client.get(f"/api/adventures/{copy_id}/export").json() assert again["format"] == bundle.FORMAT assert again["stateEvents"] == [] assert again["stateProposals"] == [] assert again["summaries"] == [] assert not any(a.get("contextSnapshotZ") for a in again["actions"]) # And the story it does have survives a second round trip unchanged. twice = bring_back(client, again) assert _tree_size(client, twice) == _tree_size(client, copy_id) def test_a_v1_file_still_imports_and_reads_in_order(client): """The flat format, with its retries as a repeating group.""" copy_id = bring_back(client, { "format": bundle.LEGACY_FORMAT, "title": "An old flat file", "memory": "Kept from before the tree.", "actions": [ {"index": 0, "type": "start", "text": "It begins."}, {"index": 1, "type": "do", "text": "look around"}, {"index": 2, "type": "ai", "text": "Take two.", "variants": [{"text": "Take one."}, {"text": "Take two."}], "variantIndex": 1}, ], }) page = client.get(f"/api/adventures/{copy_id}").json() assert [a["text"] for a in page["actions"]] == [ "It begins.", "look around", "Take two.", ] assert page["memory"] == "Kept from before the tree." # Both attempts arrived; only one is the story. with SessionLocal() as db: rows = db.query(models.Action).filter( models.Action.adventure_id == copy_id, models.Action.type == "ai", ).all() assert sorted(r.text for r in rows) == ["Take one.", "Take two."] assert sum(1 for r in rows if r.live) == 1 def test_a_pre_m2_file_with_scripting_still_imports(client, current): """M2 removed campaign scripting. Its keys are ignored, not rejected. The story, the tree and everything else in such a file are still worth importing, and refusing the campaign over a subsystem that no longer exists would lose all of it to reject one key. """ payload = as_of(current, "pre-m5") payload["scripts"] = [{"name": "onTurn", "code": "state.gold += 10"}] payload["scriptState"] = {"gold": 70} copy_id = bring_back(client, payload) assert _tree_size(client, copy_id) == len(current["actions"])