Files
interactive-story/backend/tests/test_m11_context_window.py
JesseMarkowitzandClaude Opus 5 ef25b0a876 Stop re-reading the whole prompt every turn, and let a lost run carry on
M01, the hundred-turn campaign, is the one REQUIRED test still
outstanding. Everything here is about it finishing, and being worth
believing when it does. No requirement changed, no acceptance test was
retired or relaxed, and M11 §P.1's "no performance requirement" still
stands: what changed is the cost of a turn, not what a turn contains.

An inference server caches a prompt by its prefix. The history window
gave up its oldest action every turn, which changed the prompt near the
front and threw that cache away, so nearly the whole prompt was
reprocessed every turn however little had actually changed. The window
now snaps the oldest depth to a block and holds it, stepping every few
turns. Measured on real builder output at an 8,192-token budget: 124.0s
per turn against 362.4s. The cost is history depth, bounded by
TRIM_FRACTION at a quarter of the window, which is the dial between
recent history and speed.

A run that dies no longer starts again from turn one. m11_long_run
checkpoints resume.json after the prologue, after every scheduled step
and after every turn, and --resume reattaches to the same campaign. A
finished run deletes it, so the file's presence means an unfinished run
and starting fresh over one is refused. The model timeout is an option
rather than a hard-coded 600s, a turn that overruns is a failed turn
instead of an unhandled exception that ends the run with no summary,
and a run that has stopped producing turns writes its evidence and
stops.

Two checks could not fail. M04's planted clue went into an add_fact
"detail" key that the event does not define, so it was dropped and
fact_still_in_state could never be true; it is now in "value" and
proved at turn one, which stops a run measuring nothing for hours.
m11_browser degraded silently without a narrator into two failures that
read exactly like a product regression, and now requires one, with
--no-narrator as an explicit opt-out that marks the run partial.

Window discovery speaks Ollama's native API, so against vLLM or
llama.cpp's own server the window goes unverified and the budget
uncapped -- M11's own failure mode reached by another route.
context_window_override lets the operator state what they launched the
server with, and is used only where discovery left a hole: a verified
window always wins, so a declaration can lower an unknown ceiling into
existence and never raise a known one. "verified" still means the
server answered, so window_verified in a turn's provenance keeps the
meaning M11's report counts on.

planning/README.md said the M11 tree was staged rather than committed,
in two places; it was committed and signed. Planning package v3.8.

Backend 1,376 passed, 17 skipped, 0 failed; frontend 161; lint and
build clean. Every M11 harness re-run on this tree: browser 38/0/0,
offline 23/0, identity clean, contrast unchanged, recovery 14/0 on a
small bundle. M01 itself has not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9LiyxBxnMTXV2wRjdyDGB
2026-09-10 06:13:55 -04:00

394 lines
16 KiB
Python

"""M11: the application must not silently budget more input than the server accepts.
This is the milestone's release blocker, and the failure it prevents is the
quiet kind. M8 measured a reference deployment enforcing a **4,096**-token window
while the application budgeted **16,384**. Every request returned HTTP 200. What
the server did with the excess is the problem: `llama.cpp` drops the *oldest*
tokens, and the oldest tokens here are the system block — the narrator's rules
and the campaign canon. A 100-turn certification run against that server would
have looked perfect and proved nothing.
So the tests below are in two halves.
**The probe** must find the real window, must refuse to guess when it cannot,
and must be held to the same endpoint policy as inference — a window probe that
could reach an address a turn may not would be a hole in ADR 011.
**The enforcement** is the half that matters: a verified window is a *ceiling*,
and the prompt that comes out of the builder must physically fit inside it. The
sentinel test is the one to read — a campaign whose canon sits at the front of
the prompt, a history far too long to fit, and a small verified window. The
canon must still be there afterwards. That is the difference between the
application choosing what to drop and the server choosing.
python -m pytest tests/test_m11_context_window.py -v
"""
import asyncio
import httpx
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, contextwindow, limits, models
from app.context import builder
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider
ENDPOINT = "http://127.0.0.1:11434/v1"
@pytest.fixture(autouse=True)
def _clear_window_cache():
contextwindow.cache_clear()
yield
contextwindow.cache_clear()
# ------------------------------------------------------------ the arithmetic
def test_the_native_api_sits_beside_the_openai_one():
assert contextwindow.native_base("http://127.0.0.1:11434/v1") == "http://127.0.0.1:11434"
assert contextwindow.native_base("https://box.local:59394/v1/") == "https://box.local:59394"
# Not shaped like Ollama's endpoint: used as given rather than guessed at.
assert contextwindow.native_base("http://127.0.0.1:8000") == "http://127.0.0.1:8000"
def test_a_verified_window_is_a_ceiling():
small = contextwindow.Window(4096, contextwindow.LOADED)
assert contextwindow.effective_budget(16384, small) == 4096
def test_a_smaller_configured_budget_still_wins():
"""The reader asked for a shorter prompt. The ceiling does not lengthen it."""
big = contextwindow.Window(32768, contextwindow.LOADED)
assert contextwindow.effective_budget(8000, big) == 8000
def test_an_unverified_window_changes_nothing():
assert contextwindow.effective_budget(16384, contextwindow.UNVERIFIED) == 16384
assert contextwindow.effective_budget(16384, None) == 16384
# ----------------------------------------------------------------- the probe
class FakeOllama:
"""Answers `/api/ps` and `/api/show` the way the real server does.
Built from the shapes a real Ollama 0.33 returned, recorded in the M11
report: `/api/ps` carries `context_length` for a resident model, and
`/api/show` carries a plain-text parameter block plus `model_info`.
"""
def __init__(self, *, loaded=None, parameters=None, arch_ctx=32768,
show_status=200, ps_status=200):
self.loaded = loaded or {}
self.parameters = parameters
self.arch_ctx = arch_ctx
self.show_status = show_status
self.ps_status = ps_status
self.seen: list[str] = []
def handler(self, request: httpx.Request) -> httpx.Response:
self.seen.append(str(request.url))
if request.url.path == "/api/ps":
if self.ps_status != 200:
return httpx.Response(self.ps_status)
return httpx.Response(200, json={"models": [
{"name": name, "model": name, "context_length": tokens}
for name, tokens in self.loaded.items()
]})
if request.url.path == "/api/show":
if self.show_status != 200:
return httpx.Response(self.show_status, json={})
body = {"model_info": {"qwen2.context_length": self.arch_ctx}}
if self.parameters is not None:
body["parameters"] = self.parameters
return httpx.Response(200, json=body)
return httpx.Response(404)
@pytest.fixture()
def server(monkeypatch):
"""Installs a fake Ollama behind httpx, and hands the test the recorder."""
holder = {}
def install(fake: FakeOllama):
holder["fake"] = fake
original = httpx.AsyncClient
def build(*args, **kwargs):
kwargs.pop("verify", None)
return original(*args, transport=httpx.MockTransport(fake.handler), **kwargs)
monkeypatch.setattr(contextwindow.httpx, "AsyncClient", build)
return fake
return install
def test_a_loaded_model_reports_the_window_it_is_being_served_with(server):
fake = server(FakeOllama(loaded={"qwen2.5:3b-instruct": 4096}))
window = asyncio.run(contextwindow.probe(ENDPOINT, "qwen2.5:3b-instruct"))
assert window.tokens == 4096
assert window.source == contextwindow.LOADED
assert window.verified
# Asked the running server first, because a resident model has already
# settled the question.
assert fake.seen[0].endswith("/api/ps")
def test_an_unloaded_model_falls_back_to_what_it_will_load_with(server):
server(FakeOllama(loaded={}, parameters="num_ctx 16384\n"))
window = asyncio.run(contextwindow.probe(ENDPOINT, "qwen2.5:3b-instruct-16k"))
assert (window.tokens, window.source) == (16384, contextwindow.PARAMETERS)
assert window.model_max == 32768
def test_a_model_with_no_num_ctx_is_unknown_rather_than_assumed(server):
"""The case that caused the bug, and it must not be papered over.
The server will load this at *its* default — 4,096 with no VRAM — but the
default is the server's business and is not in any answer it gave us.
Reporting 4,096 here would be a guess that happens to be right on one
machine, so this reports unknown and says why.
"""
server(FakeOllama(loaded={}, parameters=None))
window = asyncio.run(contextwindow.probe(ENDPOINT, "qwen2.5:3b-instruct"))
assert not window.verified
assert "num_ctx" in window.detail
assert window.model_max == 32768 # still useful: raising it is possible
def test_the_declared_window_cannot_exceed_the_architecture(server):
server(FakeOllama(loaded={}, parameters="num_ctx 999999\n", arch_ctx=32768))
assert asyncio.run(contextwindow.probe(ENDPOINT, "m")).tokens == 32768
def test_a_probe_obeys_the_same_endpoint_policy_as_inference():
"""ADR 011 / H12. A probe is a request, and requests go where turns may go.
No transport is installed, so a probe that ignored the policy would attempt
a real connection to a cloud host. It is refused before that.
"""
for url in ("https://api.openai.com/v1", "http://8.8.8.8:11434/v1",
"https://replicate.com/v1"):
window = asyncio.run(contextwindow.probe(url, "gpt-4"))
assert not window.verified
assert "not allowed" in window.detail
def test_an_unreachable_server_is_unknown_not_an_exception():
"""Offline is the ordinary case, and it must not cost a turn."""
window = asyncio.run(contextwindow.probe("http://127.0.0.1:1/v1", "any"))
assert not window.verified
assert window.tokens is None
def test_a_server_that_does_not_speak_ollama_is_unknown(server):
server(FakeOllama(loaded={}, show_status=404, ps_status=404))
assert not asyncio.run(contextwindow.probe(ENDPOINT, "m")).verified
def test_the_answer_is_cached_so_it_costs_one_request_a_session(server):
fake = server(FakeOllama(loaded={"m": 8192}))
for _ in range(5):
assert asyncio.run(contextwindow.probe(ENDPOINT, "m")).tokens == 8192
assert len([u for u in fake.seen if u.endswith("/api/ps")]) == 1
def test_changing_the_model_or_endpoint_forgets_what_was_learned(server):
fake = server(FakeOllama(loaded={"m": 8192, "other": 2048}))
assert asyncio.run(contextwindow.probe(ENDPOINT, "m")).tokens == 8192
assert asyncio.run(contextwindow.probe(ENDPOINT, "other")).tokens == 2048
contextwindow.cache_clear()
assert asyncio.run(contextwindow.probe(ENDPOINT, "m")).tokens == 8192
assert len([u for u in fake.seen if u.endswith("/api/ps")]) == 3
# ----------------------------------------------------------- the enforcement
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="m11cw@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="qwen2.5:3b-instruct", endpoint_url=ENDPOINT,
embedding_model="", context_token_budget=16384, max_output_tokens=800,
))
adventure = models.Adventure(
user_id=user.id, title="Windowed",
# The real canon shape — a dict of rules — not a string. The first
# version of this fixture passed a string, `_canon_section` correctly
# ignored it, and the sentinel test failed against a product that was
# behaving properly. Recorded in the M11 report as a harness defect.
campaign_canon={"rules": [
"The abbey seal has never been broken.",
"The sealed crypt is named CANON-SENTINEL-VERITAS-4417.",
]},
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text="Rain over Westhaven."))
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)
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()
Base.metadata.drop_all(bind=engine)
def _long_story(adv_id, turns=120):
"""A history far larger than any small window, written straight to the tree.
Written through the ORM rather than played, because what is under test is
the builder's arithmetic against a big story, not the turn engine.
"""
from app import tree
with SessionLocal() as db:
adventure = db.get(models.Adventure, adv_id)
for i in range(turns):
for kind, text in (
("do", f"I search the {i}th chamber of the undercroft."),
("ai", "The lantern gutters. " + ("Cold stone, and older dust. " * 40)),
):
action = models.Action(adventure_id=adv_id, type=kind, text=text)
db.add(action)
db.flush()
tree.place_action(db, adventure, action)
db.commit()
def _report(client, window):
"""Builds the prompt the way a turn would, with `window` as the server's."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
settings = db.query(models.Settings).first()
return builder.build_context(adventure, settings, window=window)
def test_a_small_verified_window_caps_the_budget(client):
_long_story(client.adv_id, turns=60)
_, _, report = _report(client, contextwindow.Window(4096, contextwindow.LOADED))
assert report["tokens"]["budget"] == 4096
assert report["tokens"]["configured_budget"] == 16384
assert report["window"]["capped"] is True
assert report["window"]["verified"] is True
def test_the_prompt_physically_fits_inside_the_verified_window(client):
"""The invariant, measured on the assembled text rather than on intent."""
_long_story(client.adv_id, turns=60)
system, story, report = _report(
client, contextwindow.Window(4096, contextwindow.LOADED))
total = builder.count_tokens(system) + builder.count_tokens(story)
reserve = report["tokens"]["output_reserve"]
assert total + reserve <= 4096, (total, reserve)
assert report["tokens"]["total"] == total
def test_the_canon_at_the_front_survives_a_window_far_too_small(client):
"""The sentinel test: the application drops history, the server never gets to.
`llama.cpp` truncates from the *front*, so if the app over-budgets, the
canon is what disappears. Here the story is 120 turns long and the window is
4,096 tokens — an enormous overflow — and the canon sentinel must still be
in the prompt, with the history cut instead.
"""
_long_story(client.adv_id, turns=120)
system, story, report = _report(
client, contextwindow.Window(4096, contextwindow.LOADED))
assert "CANON-SENTINEL-VERITAS-4417" in system
assert builder.count_tokens(system) + builder.count_tokens(story) <= 4096
# And it is the history that gave way — the oldest of it, keeping the
# newest, which is the choice the application is supposed to be making.
assert report["history"]["included"] < report["history"]["total"] / 10
assert "119th chamber" in story # the most recent turn survived
assert "0th chamber" not in story # the oldest did not
def test_without_the_cap_the_same_prompt_would_have_overflowed(client):
"""Proof the test above is testing something: the defect, reproduced.
The same campaign, the same builder, no verified window — which is exactly
what every build before M11 did — produces a prompt several times larger
than the server would read. That is the prompt whose front the server would
have silently eaten.
"""
_long_story(client.adv_id, turns=120)
system, story, _ = _report(client, None)
unbounded = builder.count_tokens(system) + builder.count_tokens(story)
assert unbounded > 4096 * 2, unbounded
def test_an_unverified_window_is_recorded_as_unverified(client):
_, _, report = _report(client, contextwindow.UNVERIFIED)
assert report["window"]["verified"] is False
assert report["window"]["capped"] is False
assert report["tokens"]["budget"] == 16384
def test_a_window_too_small_for_the_protected_context_fails_with_advice(client):
"""§32's graceful failure, with the M11 sentence added.
A 1,024-token server cannot hold the reply reserve plus the canon, and the
honest answer is a refusal that says raising the *setting* will not help,
because the setting is no longer what is binding.
"""
with pytest.raises(builder.ContextOverflow) as caught:
_report(client, contextwindow.Window(1024, contextwindow.LOADED))
message = str(caught.value)
assert "1024" in message
assert "load the model with a larger window" in message
def test_a_turn_records_the_window_it_was_built_against(client, monkeypatch):
"""End to end: the stored snapshot of a real turn carries the verdict.
This is what makes an old turn auditable — a reviewer can ask of any turn in
the campaign whether it was built against a checked window, rather than
inferring it from what the settings say today.
"""
async def verified(endpoint, model, declared=None, use_cache=True):
return contextwindow.Window(4096, contextwindow.LOADED, 32768, "fake")
monkeypatch.setattr(adventures.turns.contextwindow, "probe", verified)
ScriptedProvider.replies = ["The crypt is still sealed."]
response = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": "look at the seal"})
assert response.status_code == 200, response.text[:300]
with SessionLocal() as db:
from sqlalchemy.orm import undefer
action = (
db.query(models.Action)
.filter(models.Action.adventure_id == client.adv_id,
models.Action.type == "ai")
.options(undefer(models.Action.context_snapshot))
.order_by(models.Action.id.desc()).first()
)
snapshot = action.context_snapshot
assert snapshot["window"]["verified"] is True
assert snapshot["window"]["tokens"] == 4096
assert snapshot["tokens"]["budget"] == 4096