The release-validation milestone, and the thing it had to settle first was whether any of the earlier evidence meant what it said. M8 measured a deployment enforcing a 4,096-token input window while the application budgeted 16,384. Every request returned 200. What Ollama does with the excess is drop the oldest tokens, and the oldest tokens here are the system block — the narrator's rules and the campaign canon. A hundred-turn certification against that server would have looked perfect and proved nothing, which is why this milestone could not begin with a hundred turns. So the application asks now. Ollama's window is a property of how a model was loaded rather than of the request — sending num_ctx is accepted, ignored, and worse, reloads the model at the server's own default — so the only honest move is to find out and then tell the truth about it. /api/ps reports what a resident model is being served with, /api/show what an unloaded one will load with, both on the same host inference already uses, through the same endpoint policy and the same TLS trust store. A verified window is a ceiling on the budget; an unverified one leaves the budget alone and is recorded as unverified in the turn's own provenance, so an old turn can be asked afterwards whether it was built against a checked window. There is no third behaviour, and in particular no hard-coded 4,096: a number the server did not say would be right on one machine and wrong on the next. The proof that this is doing something is a campaign whose canon sits at the front of the prompt, 120 turns of history, and a 4,096-token window. The canon is still there afterwards and the oldest history is gone. The same campaign built the old way produces a prompt more than twice the window — the defect, reproduced, so the fix is measured against it rather than asserted. Two defects the validation found on its own, and they are the same defect twice: something was true and nobody was told. A manual state correction of four changes with one bad reference applied three, returned 201, and said nothing — while recording the refusal on the audit row nobody reads. It came to light because the identity diagnostic's own fixture was refused that way and the whole run proceeded on a campaign with no scene, which would have read as a model failure. And the narration-length setting moved no number: brief, medium and long each became one English sentence, while the numeric hint the model actually reads was derived from the global reply cap and said the same thing for all three. Both now say what they did. The other two post-M8 findings are closed as well. The tab said AI D&D, which no document had ever claimed it did not; it says Interactive Story now, with the open campaign first, and the name is the owner's decision rather than a find-and-replace to something narrower than the engine. After an Undo the reader could not tell where they had landed; the control row now ends with "Moment 11 · later story ahead", from the server's own answer, in the word the transcript already uses, with none of head, branch or depth anywhere near it. The identity diagnostic exists and the root cause does not. That campaign was destroyed, so no cause can be established — what M11 owes the finding is something that can classify the next occurrence, and a diagnostic that makes only the judgements a program can honestly make: duplicate keys, shared names, protagonist drift, state and context disagreeing. Whether prose misattributed a line is left to a person reading it beside its prompt, because a regex cannot read dialogue and one that pretended to would produce exactly the confident wrong answer this finding is about. Its detectors are proved to fire against a planted second Alice. Two entities may still share a display name. That was checked first, as the finding asked, and left permitted: a mother and a daughter, or a stranger giving a false name, are ordinary fiction, and refusing them to guard against a model mistake would refuse the wrong thing. What was missing was that it happened silently. It is reported now. Evidence, not inference: a hundred accepted turns against a real narrator with genuine process restarts; a real browser against the built SPA; a container with no network at all; a campaign moved into a data directory that never existed. Each was discarded and re-run whenever the product changed under it, and the runs that were thrown away are listed in the report with the reason, along with ten defects in the harnesses themselves — because a harness that has only ever agreed with itself is not evidence, and two of M8's five harness defects were masking real ones. No dependency was added, removed or upgraded. No acceptance test was retired, relaxed or reclassified. M11 is implemented and verified; it is not accepted, and there is no release tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""M7: opening a genuine pre-M7 database, and playing on afterwards.
|
|
|
|
Two databases are exercised, because they fail differently:
|
|
|
|
* **Fresh.** Everything is built by `create_all`, which is the path a new
|
|
install takes — and the path the FTS5 index nearly missed, because a virtual
|
|
table is not something SQLAlchemy's metadata describes.
|
|
* **A real M6 database.** Built by dropping every M7 table and index and
|
|
rewinding the stamp to 91, so the M7 migration runs its real statements
|
|
against a schema that genuinely lacks them. A current schema with an old stamp
|
|
would skip the DDL and test half the change (the lesson
|
|
`tests/schema_rewind.py` was written for).
|
|
|
|
What the second one has to prove is not "the migration completed". It is that a
|
|
campaign written before M7 existed still behaves: its history, head, branches,
|
|
Save Points, narrative state, summaries, memories, derived status and prompt
|
|
provenance are all intact, it needs no knowledge sources to play, and it can
|
|
then import one and use it.
|
|
|
|
python -m pytest tests/test_knowledge_migration.py -v
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import inspect, select, text
|
|
|
|
from app import auth, limits, memorybank, migrations, models
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.knowledge import fts
|
|
from app.main import app
|
|
from app.routers import adventures
|
|
|
|
from fakes import ScriptedProvider, state_block
|
|
|
|
M6_VERSION = 91
|
|
#: The version M7's own migration introduced. Kept as the number M7 added
|
|
#: rather than as "the newest version": M11 added 93, and a test that conflated
|
|
#: the two would fail on every later migration while proving nothing about M7.
|
|
M7_VERSION = 92
|
|
|
|
#: Everything M7 adds to the schema. Dropping all of it and rewinding the stamp
|
|
#: is what makes the fixture a real M6 database rather than a current one
|
|
#: wearing an old number.
|
|
M7_TABLES = ("knowledge_embeddings", "knowledge_chunks", "knowledge_sources")
|
|
|
|
|
|
class StubEmbedder:
|
|
async def embed(self, texts):
|
|
return [[1.0, float(len(t) % 7), 0.5] for t in texts]
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
memorybank._vector_cache.clear()
|
|
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
|
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
|
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubEmbedder())
|
|
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubEmbedder())
|
|
try:
|
|
yield _make_client()
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
memorybank._vector_cache.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _make_client():
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False, email="m7mig@example.com")
|
|
setup.add(user)
|
|
setup.flush()
|
|
setup.add(models.Settings(
|
|
user_id=user.id, model="test-model", embedding_model="",
|
|
context_token_budget=4000, max_output_tokens=300,
|
|
))
|
|
adventure = models.Adventure(user_id=user.id, title="Pre-M7 Campaign")
|
|
setup.add(adventure)
|
|
setup.flush()
|
|
setup.add(models.Action(adventure_id=adventure.id, type="start",
|
|
text="The road forks at the Crooked Lantern."))
|
|
setup.commit()
|
|
adv_id, user_id = adventure.id, user.id
|
|
setup.close()
|
|
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
|
|
test_client.user_id = user_id
|
|
return test_client
|
|
|
|
|
|
def play(client, text_, prose="The road bends on past the treeline.", events=None):
|
|
ScriptedProvider.replies = [f"{prose}\n{state_block(events or [])}"]
|
|
response = client.post(f"/api/adventures/{client.adv_id}/actions",
|
|
json={"type": "do", "text": text_})
|
|
assert response.status_code == 200, response.text[:300]
|
|
return response
|
|
|
|
|
|
def rewind_to_m6():
|
|
"""Makes the database genuinely M6: no M7 tables, no M7 index, stamp 91."""
|
|
with engine.begin() as conn:
|
|
for table in M7_TABLES:
|
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
|
conn.execute(text(f"DROP TABLE IF EXISTS {fts.TABLE}"))
|
|
conn.execute(text(f"PRAGMA user_version = {M6_VERSION}"))
|
|
|
|
|
|
def stamp():
|
|
with engine.begin() as conn:
|
|
return conn.execute(text("PRAGMA user_version")).scalar()
|
|
|
|
|
|
def upload(client, name, body, classification):
|
|
return client.post(
|
|
f"/api/adventures/{client.adv_id}/knowledge",
|
|
files={"file": (name, body.encode(), "text/markdown")},
|
|
data={"classification": classification},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------- fresh
|
|
|
|
def test_a_fresh_database_gets_every_m7_table_and_the_fts_index(client):
|
|
"""The `create_all` path, including the virtual table it cannot describe."""
|
|
tables = set(inspect(engine).get_table_names())
|
|
for table in M7_TABLES:
|
|
assert table in tables
|
|
assert fts.TABLE in tables
|
|
# Bootstrapping goes to the newest version, which is M7's or later.
|
|
assert stamp() == migrations.LATEST_VERSION >= M7_VERSION
|
|
|
|
# And it works end to end on that fresh database.
|
|
assert upload(client, "canon.md",
|
|
"# Abbey\n\nThe Old Abbey lies north of Westhaven.\n",
|
|
"canon").status_code == 201
|
|
play(client, "Aldric asks about the Old Abbey north of Westhaven.")
|
|
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
|
assert "canon.md" in [u["filename"] for u in report["knowledge"]["used"]]
|
|
|
|
|
|
# ------------------------------------------------------------ a real M6 db
|
|
|
|
def test_a_real_m6_database_migrates_and_keeps_everything_it_had(client):
|
|
"""The migration, against a database that genuinely predates M7."""
|
|
# --- build a campaign with one of everything M6 owns ---
|
|
play(client, "Aldric leaves the tavern.")
|
|
play(client, "Aldric walks the north road.",
|
|
events=[{"type": "create_entity", "entity": "aldric", "name": "Aldric",
|
|
"entity_type": "character"}])
|
|
play(client, "Aldric reaches the abbey gate.",
|
|
events=[{"type": "add_fact", "fact_id": "at-gate", "subject": "aldric",
|
|
"predicate": "stands at", "value": "the abbey gate"}])
|
|
save_point = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
|
|
json={"name": "At the gate"}).json()
|
|
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
|
|
play(client, "Aldric turns back instead.", prose="He turns back toward the town.")
|
|
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
db.add(models.Summary(
|
|
adventure_id=adventure.id, text="Aldric has been walking north.",
|
|
branch_id=adventure.head_branch_id, depth=adventure.head_depth,
|
|
source_start=0, source_end=adventure.head_depth, trigger="interval",
|
|
))
|
|
memory = models.Memory(
|
|
adventure_id=adventure.id, text="Aldric left the Crooked Lantern.",
|
|
branch_id=adventure.head_branch_id, depth=adventure.head_depth,
|
|
)
|
|
memorybank.set_vector(memory, [1.0, 2.0, 3.0])
|
|
db.add(memory)
|
|
db.add(models.DerivedStatus(
|
|
adventure_id=adventure.id, kind="summary", status="ok"))
|
|
db.commit()
|
|
|
|
before = {
|
|
"actions": client.get(f"/api/adventures/{client.adv_id}/actions").json(),
|
|
"branches": client.get(f"/api/adventures/{client.adv_id}/branches").json(),
|
|
"checkpoints": client.get(f"/api/adventures/{client.adv_id}/checkpoints").json(),
|
|
"state": client.get(f"/api/adventures/{client.adv_id}/state").json(),
|
|
"derived": client.get(f"/api/adventures/{client.adv_id}/derived").json(),
|
|
"memories": client.get(f"/api/adventures/{client.adv_id}/memories").json(),
|
|
}
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
head_before = (adventure.head_branch_id, adventure.head_depth)
|
|
state_before = adventure.narrative_state
|
|
ai_action = next(a for a in reversed(before["actions"]["actions"])
|
|
if a["type"] == "ai")
|
|
snapshot_before = client.get(
|
|
f"/api/adventures/{client.adv_id}/actions/{ai_action['id']}/context"
|
|
).json()
|
|
|
|
# --- make it an M6 database, then migrate it ---
|
|
rewind_to_m6()
|
|
tables = set(inspect(engine).get_table_names())
|
|
assert not (set(M7_TABLES) & tables)
|
|
assert fts.TABLE not in tables
|
|
assert stamp() == M6_VERSION
|
|
|
|
migrations.bootstrap(engine)
|
|
|
|
assert stamp() == migrations.LATEST_VERSION >= M7_VERSION
|
|
tables = set(inspect(engine).get_table_names())
|
|
for table in M7_TABLES + (fts.TABLE,):
|
|
assert table in tables, table
|
|
|
|
# --- everything M6 had still behaves ---
|
|
assert client.get(f"/api/adventures/{client.adv_id}/actions").json() \
|
|
== before["actions"]
|
|
assert client.get(f"/api/adventures/{client.adv_id}/branches").json() \
|
|
== before["branches"]
|
|
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json() \
|
|
== before["checkpoints"]
|
|
assert client.get(f"/api/adventures/{client.adv_id}/state").json() \
|
|
== before["state"]
|
|
assert client.get(f"/api/adventures/{client.adv_id}/memories").json() \
|
|
== before["memories"]
|
|
derived_after = client.get(f"/api/adventures/{client.adv_id}/derived").json()
|
|
assert derived_after["summaries"] == before["derived"]["summaries"]
|
|
assert derived_after["status"] == before["derived"]["status"]
|
|
with SessionLocal() as db:
|
|
adventure = db.get(models.Adventure, client.adv_id)
|
|
assert (adventure.head_branch_id, adventure.head_depth) == head_before
|
|
assert adventure.narrative_state == state_before
|
|
|
|
# Prompt provenance from before the migration is still readable, and its
|
|
# M6 components are unchanged.
|
|
snapshot_after = client.get(
|
|
f"/api/adventures/{client.adv_id}/actions/{ai_action['id']}/context"
|
|
).json()
|
|
assert snapshot_after["sections"] == snapshot_before["sections"]
|
|
assert snapshot_after["summary"] == snapshot_before["summary"]
|
|
assert snapshot_after["memories"] == snapshot_before["memories"]
|
|
|
|
# The campaign needs no knowledge sources to keep playing.
|
|
assert client.get(f"/api/adventures/{client.adv_id}/knowledge").json() == []
|
|
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
|
assert report["knowledge"]["used"] == []
|
|
assert not any(s["label"].startswith("imported_") for s in report["sections"])
|
|
play(client, "Aldric keeps walking.")
|
|
|
|
# Undo, Redo and Save Point restore all still work after the migration.
|
|
assert client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200
|
|
assert client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200
|
|
assert client.post(
|
|
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore"
|
|
).status_code == 200
|
|
|
|
# --- and it can now use the new subsystem ---
|
|
assert upload(client, "canon.md",
|
|
"# The Abbey\n\nThe Old Abbey lies five miles north of "
|
|
"Westhaven and its crypt bears a broken circle.\n",
|
|
"canon").status_code == 201
|
|
play(client, "Aldric asks about the Old Abbey and its broken-circle symbol.")
|
|
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
|
|
assert "canon.md" in [u["filename"] for u in report["knowledge"]["used"]]
|
|
|
|
|
|
def test_the_migration_is_idempotent(client):
|
|
"""Running it twice is not a second migration."""
|
|
rewind_to_m6()
|
|
migrations.bootstrap(engine)
|
|
upload(client, "canon.md", "# Abbey\n\nThe abbey stands.\n", "canon")
|
|
with SessionLocal() as db:
|
|
rows = len(db.execute(select(models.KnowledgeChunk)).scalars().all())
|
|
|
|
migrations.bootstrap(engine)
|
|
assert stamp() == migrations.LATEST_VERSION >= M7_VERSION
|
|
with SessionLocal() as db:
|
|
assert len(db.execute(select(models.KnowledgeChunk)).scalars().all()) == rows
|
|
assert len(client.get(f"/api/adventures/{client.adv_id}/knowledge").json()) == 1
|
|
|
|
|
|
def test_the_fts_index_is_dropped_with_the_table_it_indexes():
|
|
"""`create_all`/`drop_all` carry the virtual table both ways.
|
|
|
|
Without this, a teardown would leave the index holding rowids for chunks
|
|
that no longer exist, and the next campaign's first passage would inherit a
|
|
stranger's search results.
|
|
"""
|
|
Base.metadata.create_all(bind=engine)
|
|
assert fts.TABLE in inspect(engine).get_table_names()
|
|
Base.metadata.drop_all(bind=engine)
|
|
assert fts.TABLE not in inspect(engine).get_table_names()
|
|
Base.metadata.create_all(bind=engine)
|
|
with engine.begin() as conn:
|
|
assert conn.execute(text(f"SELECT count(*) FROM {fts.TABLE}")).scalar() == 0
|
|
Base.metadata.drop_all(bind=engine)
|