M6: branch-safe context, summaries and long-term story memory

Aligns the inherited AI-DnD memory and context foundation with the history,
authority and state model M3-M5 established. Long stories now reach the narrator
through a bounded, lineage-safe, inspectable context rather than a growing
transcript.

This commit includes the corrective work that followed the independent review in
planning/reports/M6-IMPLEMENTATION-REPORT.md. The first implementation reported
E03 as passing and it was not; the report records that history rather than
hiding it.

What was already correct, and was kept rather than rebuilt

  Memory lineage. Memories already carried (branch_id, depth) and retrieval
  already filtered through the capped-path clause; the ten-step negative control
  was measured passing against b7005e6 before any change here. M6 adds the
  regression tests that pin it, plus provenance and authority on the result.

Summary lineage — both halves

  A summary is a row carrying the coordinate of the last node it covers, and
  eligibility is the same head-capped lineage clause memories use. That alone
  was not enough: generation was seeded from adventures.story_summary, a
  campaign-global column with no lineage, so after a divergence the summariser
  was handed the abandoned line's prose and asked to update it. The row it
  produced was correctly anchored and therefore looked safe while its sentences
  described a story the reader had left.

  Generation is now seeded from summaries.current — the same question the
  context builder asks — so the input and the output are scoped by one rule.
  adventures.story_summary remains a reader-facing mirror for the Plot panel and
  the export bundle, kept in step when a summary is written and when the head
  moves, and nothing authoritative reads it.

Retrieval redundancy

  With a real embedding model, four near-identical memories crowded out the one
  distinctive clue, which survived only because the default memory_top_k is 5.
  Retrieval now drops a candidate that repeats one already chosen, never across
  authority classes, at a threshold measured against the configured embedding
  model. The clue is retrieved at top_k 5, 4 and 3. Ranking itself is unchanged;
  the further factors CONTEXT-AND-MEMORY §20 contemplates remain unimplemented
  and are recorded as such.

Memory authority, budgeting, observability

  Memory.authority is accepted_story or heuristic, classified by the application
  and marked in the prompt; retrieval never writes state. The reply is reserved
  out of the context budget, and an impossible configuration fails clearly
  instead of overflowing. Each derived pass records ok/idle/failed per campaign,
  served by GET /adventures/{id}/derived and shown in Insights, so the M2
  failure — a dead memory bank with a green suite — is visible if it recurs.
  Provider-wiring tests mock no factory.

Also: two pre-existing test-suite leaks fixed; two fixtures that stored one
vector in every memory now use distinct ones, so lineage assertions stay
readable alongside redundancy suppression.

Planning: CONTEXT-AND-MEMORY, TECHNICAL-DESIGN, DATA-MODEL, V1-ACCEPTANCE-TESTS,
BUILD-MILESTONES, VERSION and planning/README updated to describe what exists,
including that a valid E03 test must regenerate a summary after diverging. The
M5 report was rotated to planning/archive/milestone-reports/. No new ADR — every
choice implements a decision the package had already settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
This commit is contained in:
JesseMarkowitz
2026-09-06 03:00:33 -04:00
co-authored by Claude Opus 5
parent b7005e6fdd
commit a6e9c7a32b
32 changed files with 4040 additions and 84 deletions
+10 -2
View File
@@ -34,9 +34,9 @@ agreed in every group.
import copy import copy
from sqlalchemy.orm import Session, undefer from sqlalchemy.orm import Session, object_session, undefer
from . import models from . import models, summaries
from .context import lineage from .context import lineage
from .narrative import model as narrative_model from .narrative import model as narrative_model
@@ -191,6 +191,14 @@ def restore_state(adventure: models.Adventure, node: models.Action | None) -> No
if isinstance(node.narrative_state_after, dict) if isinstance(node.narrative_state_after, dict)
else narrative_model.empty() else narrative_model.empty()
) )
# M6: the reader-facing summary mirror follows the head too. It is a
# convenience column with no lineage of its own, so without this it would go
# on showing a summary belonging to a position the story has left. Nothing
# authoritative reads it — the prompt takes its summary from
# `summaries.current` — but the Plot panel and the export bundle do.
session = object_session(adventure)
if session is not None:
summaries.refresh_mirror(session, adventure)
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay # Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
# coherent with the position being read, so an old save is not left showing # coherent with the position being read, so an old save is not left showing
# a future's values. Nothing consults them to decide anything. # a future's values. Nothing consults them to decide anything.
+2 -1
View File
@@ -1,5 +1,6 @@
from . import history from . import history
from .builder import ( from .builder import (
ContextOverflow,
build_context, build_context,
count_tokens, count_tokens,
match_cards, match_cards,
@@ -9,7 +10,7 @@ from .builder import (
from .history import story_actions from .history import story_actions
__all__ = [ __all__ = [
"build_context", "ContextOverflow", "build_context",
"count_tokens", "count_tokens",
"history", "history",
"match_cards", "match_cards",
+93 -6
View File
@@ -21,8 +21,9 @@ block and the live sections in `build_context`.
from dataclasses import dataclass from dataclasses import dataclass
import tiktoken import tiktoken
from sqlalchemy.orm import object_session
from .. import models, narrative, worldstate from .. import derived, models, narrative, summaries, worldstate
from . import encoding, history from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
@@ -63,6 +64,24 @@ MAX_LENGTH_FLOOR_WORDS = 300
# Built from the table vendored in `encoding.py`, not fetched: the upstream # Built from the table vendored in `encoding.py`, not fetched: the upstream
# `tiktoken.get_encoding("cl100k_base")` downloads it on first use, and this # `tiktoken.get_encoding("cl100k_base")` downloads it on first use, and this
# is called on every turn. # is called on every turn.
# M6: added to the configured reply budget when reserving output space. It
# absorbs the section separators added after budgeting and the drift between
# this tokenizer and the serving model's. Fixed rather than proportional: what
# it covers does not grow with the size of the budget.
OUTPUT_SAFETY_MARGIN = 64
class ContextOverflow(RuntimeError):
"""Raised when protected context alone cannot fit in the token budget.
Protected means the narrator rules, the campaign canon, the authoritative
narrative state, the reader's own input, and the reserve for the reply
(`CONTEXT-AND-MEMORY.md` §30). None of those may be dropped to make room for
old prose, so when they do not fit there is no prompt to build and saying so
is the only honest answer.
"""
def _encoding() -> tiktoken.Encoding: def _encoding() -> tiktoken.Encoding:
return encoding.get_encoding() return encoding.get_encoding()
@@ -187,6 +206,12 @@ def _history_text(action: models.Action) -> str:
return action.text return action.text
def _memory_line(memory: dict) -> str:
"""One retrieved memory, marked with its authority (M6)."""
mark = " [inferred]" if memory.get("authority") == "heuristic" else ""
return f"-{mark} {memory['text']}"
def _canon_section(adventure: models.Adventure) -> str: def _canon_section(adventure: models.Adventure) -> str:
"""The campaign's own rules, rendered for the system block. """The campaign's own rules, rendered for the system block.
@@ -317,15 +342,33 @@ def build_context(
# memories change on most turns, and the stat values change on nearly every # memories change on most turns, and the stat values change on nearly every
# turn. `world_lore` is added below, because the history window determines # turn. `world_lore` is added below, because the history window determines
# which cards trigger and that window is not known yet. # which cards trigger and that window is not known yet.
# M6: the summary the *current lineage* is entitled to, not whatever was
# written last. A summary is derived data anchored to the story it covers,
# so an Undo or a divergence makes an old one ineligible rather than
# leaking it into a story it does not describe (E03, `app/summaries.py`).
db = object_session(adventure)
summary_row = summaries.current(db, adventure) if db is not None else None
summary_text = summary_row.text.strip() if summary_row is not None else ""
summary_section = ( summary_section = (
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}") Section("story_summary", f"Story summary:\n{summary_text}")
if adventure.story_summary.strip() if summary_text
else None else None
) )
memories_section = None memories_section = None
if memory_bank and memory_bank.get("used"): if memory_bank and memory_bank.get("used"):
lines_text = "\n".join(f"- {m['text']}" for m in memory_bank["used"]) # M6: an inference must not read as a record. A heuristic memory is
memories_section = Section("used_memories", f"Memories:\n{lines_text}") # marked in the prompt itself, because the narrator decides what to
# treat as established from what it is shown, and an unlabelled guess
# sitting beside accepted history is how a guess becomes canon
# (`CONTEXT-AND-MEMORY.md` §14). Authoritative state changes still come
# only from the M5 event path, whatever a memory says.
lines_text = "\n".join(_memory_line(m) for m in memory_bank["used"])
memories_section = Section(
"used_memories",
"Memories from earlier in the story. Lines marked [inferred] are "
"interpretation, not established fact — do not treat them as "
f"settled truth:\n{lines_text}",
)
world_state_section = None world_state_section = None
refusal_note = "" refusal_note = ""
# M5: the authoritative narrative state, as the model is shown it. Read from # M5: the authoritative narrative state, as the model is shown it. Read from
@@ -370,7 +413,35 @@ def build_context(
+ count_tokens(narrative.extract.EMIT_REMINDER) + count_tokens(narrative.extract.EMIT_REMINDER)
+ count_tokens(refusal_note) + count_tokens(refusal_note)
) )
available = max(256, settings.context_token_budget - reserved)
# ----- M6: the output reserve, and what happens when it does not fit -----
#
# `context_token_budget` is the whole window the model is given, so the
# narrator's reply has to be subtracted from it before any history is
# chosen. Until M6 it was not: the builder spent the entire budget on input
# and left the reply to fit in whatever the endpoint had left, which is a
# truncated turn on a model whose window is the budget
# (`CONTEXT-AND-MEMORY.md` §32, acceptance test F04).
#
# The margin covers what is added after this arithmetic — the separators
# between sections, and the difference between our tokenizer's count and the
# serving model's. It is small and fixed rather than proportional, because
# what it absorbs does not scale with the budget.
output_reserve = max(0, settings.max_output_tokens) + OUTPUT_SAFETY_MARGIN
protected = reserved + output_reserve
if protected >= settings.context_token_budget:
# Failing here is the point. The alternative — carrying on with a token
# or two of history — builds a prompt that is known to overflow, and
# the reader gets a truncated reply with no explanation. §32: "fail
# gracefully if protected context alone is too large."
raise ContextOverflow(
f"The protected context needs {protected} tokens "
f"({reserved} of prompt plus {output_reserve} reserved for the "
f"reply) but the context budget is {settings.context_token_budget}. "
"Raise the context budget, lower the maximum reply length, or "
"shorten the campaign's canon, instructions and persona."
)
available = settings.context_token_budget - protected
# Only the newest actions can reach the prompt, because the code below # Only the newest actions can reach the prompt, because the code below
# either truncates the text to `available` tokens or stops at the budget. # either truncates the text to `available` tokens or stops at the budget.
@@ -475,12 +546,28 @@ def build_context(
{"label": s.label, "text": s.text, "tokens": s.tokens} for s in all_sections {"label": s.label, "text": s.text, "tokens": s.tokens} for s in all_sections
], ],
"prompt": {"system": system_text, "story": story_text}, "prompt": {"system": system_text, "story": story_text},
# M6: the numbers the reader needs to answer "how much did each part
# cost, and what was left for the reply?" (F04, F05). `available` is
# what the history was actually allowed to spend after everything
# protected was subtracted.
"tokens": { "tokens": {
"total": count_tokens(system_text) + count_tokens(story_text), "total": count_tokens(system_text) + count_tokens(story_text),
"budget": settings.context_token_budget, "budget": settings.context_token_budget,
"output_reserve": output_reserve,
"protected": reserved,
"available_for_history": available,
"history_spent": spent,
}, },
"cards": card_records, "cards": card_records,
"memories": memory_bank, "memories": memory_bank,
# M6: which summary was used, and which stretch of story it covers, so
# "what history did that summary cover?" is answerable from the record
# rather than by guessing (F05, F06).
"summary": summaries.provenance(summary_row),
# M6: whether background derived work is currently failing for this
# campaign. A dead memory bank is visible here rather than only in a log
# nobody reads (F08).
"derived": derived.report(db, adventure.id) if db is not None else [],
"history": { "history": {
"included": len(included_actions), "included": len(included_actions),
# The count covers the whole story rather than the window fetched # The count covers the whole story rather than the window fetched
+113
View File
@@ -0,0 +1,113 @@
"""M6: recording whether background derived work succeeded, and why not.
M2 shipped with the entire memory bank dead and the full test suite green. The
summariser and the embedder raised `AttributeError` inside a fire-and-forget
task: no user-visible error, no log a player would look at, and no failing test,
because every memory test stubbed the provider factories out
(`BUILD-MILESTONES.md`, note from M2; `M2-IMPLEMENTATION-REPORT.md` §A.1).
Two rules follow, and they pull in opposite directions:
* **Derived work must fail softly.** A memory that could not be written, a
summary that could not be generated, an embedding the endpoint refused —
none of these may roll back the accepted narration, the accepted state
events, the authoritative document, the head, or the transcript. The story
turn already happened; the derived work is a commentary on it.
* **It must fail visibly.** Soft failure without a record is what M2 shipped.
So each attempt writes its outcome to one row per (campaign, kind), and that row
is readable through the API. This is deliberately not a job framework: it holds
what happened last, not a queue. Retrying is just running the pass again, which
the ordinary post-turn path already does on the next accepted turn.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import models
log = logging.getLogger(__name__)
# The kinds of derived work. Each is independent: embeddings can be failing
# while summaries succeed, and a reader should be able to see exactly that.
MEMORY = "memory"
SUMMARY = "summary"
EMBEDDING = "embedding"
KINDS = (MEMORY, SUMMARY, EMBEDDING)
def _row(db: Session, adventure_id: int, kind: str) -> models.DerivedStatus:
row = db.execute(
select(models.DerivedStatus).where(
models.DerivedStatus.adventure_id == adventure_id,
models.DerivedStatus.kind == kind,
)
).scalars().first()
if row is None:
row = models.DerivedStatus(adventure_id=adventure_id, kind=kind)
db.add(row)
return row
def succeeded(db: Session, adventure_id: int, kind: str, *, did_work: bool = True) -> None:
"""Records a clean run, clearing any standing failure.
`did_work` separates a pass that produced something from one that found
nothing to do (M6 review finding M6-F5). Both are healthy, and neither is a
failure, but reporting "ok" for a pass that has never actually run reads as
"embeddings are working" when nothing has been embedded. `idle` says the
true thing: it ran, and there was nothing pending.
"""
row = _row(db, adventure_id, kind)
row.status = "ok" if did_work else "idle"
row.detail = ""
row.failures = 0
row.last_attempt_at = models.utcnow()
if did_work:
row.last_success_at = row.last_attempt_at
def failed(db: Session, adventure_id: int, kind: str, exc: BaseException) -> None:
"""Records a failed run, keeping the reason where someone can find it.
The detail is the exception's type and message rather than a traceback: it
is shown to a reader in the Insights panel, and `ProviderError: connection
refused` is the part that tells them what to do. The traceback goes to the
log for a maintainer.
"""
row = _row(db, adventure_id, kind)
row.status = "failed"
row.detail = f"{type(exc).__name__}: {exc}"[:2000]
row.failures = (row.failures or 0) + 1
row.last_attempt_at = models.utcnow()
log.exception("derived %s work failed for adventure %s", kind, adventure_id)
def report(db: Session, adventure_id: int) -> list[dict]:
"""Every kind's last outcome, for the API and the prompt inspector."""
rows = db.execute(
select(models.DerivedStatus)
.where(models.DerivedStatus.adventure_id == adventure_id)
.order_by(models.DerivedStatus.kind)
).scalars().all()
return [
{
"kind": row.kind,
"status": row.status,
"detail": row.detail,
"failures": row.failures,
"last_attempt_at": row.last_attempt_at.isoformat() if row.last_attempt_at else None,
"last_success_at": row.last_success_at.isoformat() if row.last_success_at else None,
}
for row in rows
]
def failing(db: Session, adventure_id: int) -> list[str]:
"""The kinds currently in a failed state, for a compact UI badge."""
return [entry["kind"] for entry in report(db, adventure_id)
if entry["status"] == "failed"]
+242 -36
View File
@@ -27,13 +27,14 @@ succeeds.
""" """
import asyncio import asyncio
import logging
from array import array from array import array
from collections import OrderedDict from collections import OrderedDict
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session, defer, object_session from sqlalchemy.orm import Session, defer, object_session
from . import models, tree, vectors from . import derived, models, summaries, tree, vectors
from .context import ( from .context import (
cursors, cursors,
history, history,
@@ -46,6 +47,8 @@ from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError from .providers import OpenAICompatibleProvider, ProviderError
from .vectors import cosine # re-exported: the ranking lives here, the maths there from .vectors import cosine # re-exported: the ranking lives here, the maths there
log = logging.getLogger(__name__)
MEMORY_INTERVAL = 6 # actions per memory MEMORY_INTERVAL = 6 # actions per memory
MEMORY_START = 12 # first memory once the adventure reaches this many actions MEMORY_START = 12 # first memory once the adventure reaches this many actions
SUMMARY_INTERVAL = 15 # actions between Story Summary updates SUMMARY_INTERVAL = 15 # actions between Story Summary updates
@@ -146,6 +149,104 @@ _tasks: set[asyncio.Task] = set()
# They used to also read an API key, which is gone: Ollama does not use one and # They used to also read an API key, which is gone: Ollama does not use one and
# M2 removed cloud providers. `summary_model` and `embedding_model` fall back to # M2 removed cloud providers. `summary_model` and `embedding_model` fall back to
# the narrator model when the user has not named a separate one. # the narrator model when the user has not named a separate one.
# M6: the words that mark a memory as an interpretation rather than a record.
#
# The application owns this classification, not the model
# (`CONTEXT-AND-MEMORY.md` §14, §15). The extractor writes prose; this decides
# what weight the narrator is told to give it. The list is deliberately short
# and readable: a memory that hedges is a reading of the story, not a fact the
# story established, and the narrator must not be able to promote it to canon.
#
# Being wrong in the cautious direction is cheap — a hedged record labelled
# heuristic is still retrieved and still useful. Being wrong the other way is
# what turns a guess into canon, which is the failure §14 exists to prevent.
HEURISTIC_MARKERS = (
"seemed", "seems", "appeared to", "appears to", "apparently", "perhaps",
"maybe", "might have", "may have", "possibly", "presumably", "suggested that",
"suggests that", "implied", "implies", "as if", "likely", "probably",
"seemingly", "hinted", "hints that", "suspects", "suspected", "believes",
"believed", "wondered whether", "wonders whether",
)
ACCEPTED_STORY = "accepted_story"
HEURISTIC = "heuristic"
# M6 corrective (review finding M6-F2). How close two memories have to be before
# the second one is treated as saying nothing new.
#
# The value is measured, not guessed. Against the configured local embedding
# model, on a fixture of near-identical "the party walks the muddy road"
# memories and a set of genuinely distinct ones:
#
# redundant pairs cosine 0.938 - 0.996
# distinct pairs cosine 0.349 - 0.906
#
# 0.93 sits in that gap. The same measurement ruled out the more obvious
# lexical test: word overlap fires hardest on exactly the pair that must NOT be
# merged — "Mara promised to return before dawn" against "Aldric promised to
# return before dawn" shares 71% of its words while meaning something else —
# and is weakest (27%) on filler that plainly repeats itself. Wording is a poor
# proxy for sameness of fact; the embedding is a better one.
#
# The threshold is model-dependent by nature. A different embedding model may
# need a different number, which is why the measurement is written down here
# rather than the value alone.
REDUNDANT_SIMILARITY = 0.93
def _drop_redundant(candidates, vectors, authority_of, limit):
"""Fills `limit` slots, skipping memories that repeat one already chosen.
Greedy over the ranked list, so the highest-scoring statement of a fact is
the one kept and its provenance is the provenance that survives. Two rules
keep this from losing information:
* **Authority is never crossed.** An inference and a record are different
kinds of claim even when they read alike, so a `heuristic` memory can
never suppress an `accepted_story` one or the reverse.
* **The bar is high.** Missing a duplicate costs some budget; dropping a
distinct fact costs the narrator something it needed. The threshold is
set where the measurement says distinct facts stop appearing.
Returns `(kept, suppressed)`, the second for the inspector — a reader
should be able to see that memories were considered and set aside rather
than never retrieved.
"""
kept: list = []
suppressed: list = []
for row in candidates:
if len(kept) >= limit:
break
_score, memory_id, _pinned = row
vector = vectors.get(memory_id)
duplicate_of = None
if vector is not None:
for _kept_score, kept_id, _ in kept:
if authority_of.get(kept_id) != authority_of.get(memory_id):
continue
other = vectors.get(kept_id)
if other is not None and cosine(vector, other) >= REDUNDANT_SIMILARITY:
duplicate_of = kept_id
break
if duplicate_of is None:
kept.append(row)
else:
suppressed.append((memory_id, duplicate_of))
return kept, suppressed
def classify_authority(text: str) -> str:
"""Returns `accepted_story` or `heuristic` for one memory's text.
Hedged language is the signal. "Aldric promised Mara he would return before
dawn" is something the story established; "Mara seemed uneasy when Captain
Vale was mentioned" is an inference about it, and the prompt has to say so.
"""
lowered = text.lower()
return HEURISTIC if any(m in lowered for m in HEURISTIC_MARKERS) else ACCEPTED_STORY
def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider: def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider( return OpenAICompatibleProvider(
settings.endpoint_url, settings.endpoint_url,
@@ -473,7 +574,7 @@ async def retrieve_memories(
# affordable because memories are sparse, at roughly one per six actions, so # affordable because memories are sparse, at roughly one per six actions, so
# even a heavily forked story returns only tens of small rows. # even a heavily forked story returns only tens of small rows.
catalogue = db.execute( catalogue = db.execute(
select(models.Memory.id, models.Memory.pinned).where( select(models.Memory.id, models.Memory.pinned, models.Memory.authority).where(
models.Memory.adventure_id == adventure.id, models.Memory.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Memory), lineage.path_of(db, adventure).clause(models.Memory),
models.Memory.forgotten.is_(False), models.Memory.forgotten.is_(False),
@@ -495,11 +596,12 @@ async def retrieve_memories(
except ProviderError as exc: except ProviderError as exc:
return {"used": [], "error": str(exc)} return {"used": [], "error": str(exc)}
held = _vectors_for(db, adventure.id, [memory_id for memory_id, _ in catalogue]) held = _vectors_for(db, adventure.id, [memory_id for memory_id, _, _ in catalogue])
authority_of = {memory_id: authority for memory_id, _, authority in catalogue}
scored = sorted( scored = sorted(
( (
(cosine(query_vec, held[memory_id]), memory_id, pinned) (cosine(query_vec, held[memory_id]), memory_id, pinned)
for memory_id, pinned in catalogue for memory_id, pinned, _ in catalogue
if memory_id in held if memory_id in held
), ),
key=lambda row: row[0], key=lambda row: row[0],
@@ -511,19 +613,31 @@ async def retrieve_memories(
top_k = max(1, settings.memory_top_k) top_k = max(1, settings.memory_top_k)
used = [row for row in scored if row[2]] used = [row for row in scored if row[2]]
remaining = max(0, top_k - len(used)) remaining = max(0, top_k - len(used))
used += [row for row in scored if not row[2]][:remaining] candidates = [row for row in scored if not row[2]]
kept, suppressed = _drop_redundant(candidates, held, authority_of, remaining)
used += kept
used.sort(key=lambda row: row[0], reverse=True) used.sort(key=lambda row: row[0], reverse=True)
if not used: if not used:
return {"used": [], "error": None} return {"used": [], "error": None}
# Fetch the text only now, and only for the `top_k` rows that were chosen. # Fetch the text only now, and only for the `top_k` rows that were chosen.
#
# M6 adds authority and provenance to this same read rather than to a second
# one. The columns are narrow, the row set is `top_k`, and fetching them
# here is what keeps "why did the narrator remember this?" answerable
# without a query per memory (F06, and the N+1 discipline M5 restored).
used_ids = [memory_id for _, memory_id, _ in used] used_ids = [memory_id for _, memory_id, _ in used]
texts = dict( detail = {
db.execute( row.id: row
select(models.Memory.id, models.Memory.text) for row in db.execute(
.where(models.Memory.id.in_(used_ids)) select(
models.Memory.id, models.Memory.text, models.Memory.authority,
models.Memory.branch_id, models.Memory.depth,
models.Memory.source_start, models.Memory.source_end,
).where(models.Memory.id.in_(used_ids))
).all() ).all()
) }
texts = {memory_id: row.text for memory_id, row in detail.items()}
if update_stats: if update_stats:
# Pass `synchronize_session=False` because nothing in this request # Pass `synchronize_session=False` because nothing in this request
@@ -539,10 +653,29 @@ async def retrieve_memories(
return { return {
"used": [ "used": [
{"id": memory_id, "text": texts.get(memory_id, ""), {
"similarity": round(score, 4), "pinned": pinned} "id": memory_id,
"text": texts.get(memory_id, ""),
"similarity": round(score, 4),
"pinned": pinned,
# M6: what weight this carries, and where it came from.
"authority": getattr(detail.get(memory_id), "authority", ACCEPTED_STORY),
"source": {
"branch_id": getattr(detail.get(memory_id), "branch_id", None),
"depth": getattr(detail.get(memory_id), "depth", None),
"source_start": getattr(detail.get(memory_id), "source_start", None),
"source_end": getattr(detail.get(memory_id), "source_end", None),
},
}
for score, memory_id, pinned in used for score, memory_id, pinned in used
], ],
"considered": len(catalogue),
# M6: how many candidates were set aside as repeating one already
# chosen. Visible so that "why is that memory not here?" has an answer.
"suppressed": [
{"id": memory_id, "duplicate_of": kept_id}
for memory_id, kept_id in suppressed
],
"error": None, "error": None,
} }
@@ -587,17 +720,55 @@ async def run_post_turn(adventure_id: int) -> None:
# An anchor past the tip is not an invalid value. `settled_after` # An anchor past the tip is not an invalid value. `settled_after`
# reports that there is nothing to do, and once the story grows past the # reports that there is nothing to do, and once the story grows past the
# anchor the pass resumes where it stopped. # anchor the pass resumes where it stopped.
# M6. Each kind runs inside its own recorder, so one failing pass
# neither hides the others nor takes the turn down with it. The accepted
# narration, its state events, the authoritative document and the head
# were all committed before this task started; nothing here may undo
# them, and nothing here may fail without leaving a record
# (`BUILD-MILESTONES.md`, note from M2).
if adventure.auto_summarize: if adventure.auto_summarize:
await _create_due_memories(adventure, settings, db) await _guarded(db, adventure_id, derived.MEMORY,
await _update_story_summary(adventure, settings, db) _create_due_memories(adventure, settings, db))
await _guarded(db, adventure_id, derived.SUMMARY,
_update_story_summary(adventure, settings, db))
if adventure.memory_bank_enabled and settings.embedding_model.strip(): if adventure.memory_bank_enabled and settings.embedding_model.strip():
await _embed_pending(adventure, settings, db) await _guarded(db, adventure_id, derived.EMBEDDING,
_embed_pending(adventure, settings, db))
_evict_over_capacity(adventure, settings, db) _evict_over_capacity(adventure, settings, db)
except BaseException as exc: # noqa: BLE001 - the task boundary
# Anything the per-kind guards did not catch: a failure in the shared
# setup above, or in eviction. M2's lesson is that the one thing this
# may not do is vanish. Re-raising would only feed an unobserved task.
try:
derived.failed(db, adventure_id, derived.MEMORY, exc)
db.commit()
except BaseException: # noqa: BLE001 - the recorder must not mask it
log.exception("could not record derived-work failure for %s", adventure_id)
finally: finally:
db.close() db.close()
_running.discard(adventure_id) _running.discard(adventure_id)
async def _guarded(db: Session, adventure_id: int, kind: str, coro) -> None:
"""Runs one derived pass, recording whether it worked.
The pass keeps whatever it committed before it failed — a memory written
two blocks ago stays written — because derived work is additive and
partial progress is still progress. What must not survive is an
uncommitted, half-written unit of work, so the session is rolled back to
the last commit before the failure is recorded.
"""
try:
did_work = await coro
except BaseException as exc: # noqa: BLE001 - one kind must not stop another
db.rollback()
derived.failed(db, adventure_id, kind, exc)
db.commit()
else:
derived.succeeded(db, adventure_id, kind, did_work=bool(did_work))
db.commit()
async def summarize_block( async def summarize_block(
adventure: models.Adventure, adventure: models.Adventure,
provider: OpenAICompatibleProvider, provider: OpenAICompatibleProvider,
@@ -629,37 +800,39 @@ async def summarize_block(
async def _create_due_memories( async def _create_due_memories(
adventure: models.Adventure, settings: models.Settings, db: Session adventure: models.Adventure, settings: models.Settings, db: Session
) -> None: ) -> int:
"""Writes the memories that are due. Returns how many it wrote (M6-F5)."""
provider = summary_provider(settings) provider = summary_provider(settings)
written = 0
for _ in range(MAX_MEMORIES_PER_RUN): for _ in range(MAX_MEMORIES_PER_RUN):
# Re-read the anchor on every pass. Committing a memory does not change # Re-read the anchor on every pass. Committing a memory does not change
# the story, but this loop is the only code that moves the anchor, so # the story, but this loop is the only code that moves the anchor, so
# both numbers must be current. # both numbers must be current.
anchor = cursors.MEMORY.depth(db, adventure) anchor = cursors.MEMORY.depth(db, adventure)
if history.count_after(adventure, anchor) < MEMORY_INTERVAL + SETTLE_SLACK: if history.count_after(adventure, anchor) < MEMORY_INTERVAL + SETTLE_SLACK:
return # No settled block of story sits past the mark. The block return written # No settled block of story sits past the mark. The block
# itself is still MEMORY_INTERVAL actions; the slack asks # itself is still MEMORY_INTERVAL actions; the slack asks
# for story past its end. See `SETTLE_SLACK`. # for story past its end. See `SETTLE_SLACK`.
if history.count(adventure) < MEMORY_START: if history.count(adventure) < MEMORY_START:
return # The adventure is too short to have started summarizing. return written # The adventure is too short to have started summarizing.
# The order of those two checks is deliberate. The usual answer is that # The order of those two checks is deliberate. The usual answer is that
# no memory is due, and the first check settles that without measuring # no memory is due, and the first check settles that without measuring
# the length of the whole story. # the length of the whole story.
block = history.after(adventure, anchor, MEMORY_INTERVAL) block = history.after(adventure, anchor, MEMORY_INTERVAL)
if len(block) < MEMORY_INTERVAL: if len(block) < MEMORY_INTERVAL:
return return written
try: # A provider failure is no longer caught here. `_guarded` records it
# against this campaign, and the cursor is unchanged either way, so the
# next accepted turn retries this same block (M6).
text = await summarize_block(adventure, provider, block) text = await summarize_block(adventure, provider, block)
except ProviderError:
return # Logged on the debug page. The cursor is unchanged, so the
# next turn retries this block.
if not text: if not text:
return return written
memory = models.Memory( memory = models.Memory(
adventure_id=adventure.id, adventure_id=adventure.id,
text=text, text=text,
source_start=block[0].depth, source_start=block[0].depth,
source_end=block[-1].depth, source_end=block[-1].depth,
authority=classify_authority(text),
) )
# Attach the memory to the node it summarizes, so that a fork inherits # Attach the memory to the node it summarizes, so that a fork inherits
# the memories of the path it forked from and no others. Then move the # the memories of the path it forked from and no others. Then move the
@@ -670,22 +843,27 @@ async def _create_due_memories(
db.add(memory) db.add(memory)
cursors.MEMORY.anchor_at(adventure, block[-1]) cursors.MEMORY.anchor_at(adventure, block[-1])
db.commit() db.commit()
written += 1
return written
async def _update_story_summary( async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session adventure: models.Adventure, settings: models.Settings, db: Session
) -> None: ) -> bool:
"""Rolls the summary forward when enough new story has settled.
Returns whether it wrote one (M6-F5)."""
anchor = cursors.SUMMARY.depth(db, adventure) anchor = cursors.SUMMARY.depth(db, adventure)
uncovered = history.count_after(adventure, anchor) uncovered = history.count_after(adventure, anchor)
if uncovered < SUMMARY_INTERVAL: if uncovered < SUMMARY_INTERVAL:
return return False
# Where the summary stands once this run succeeds. Read this before the AI # Where the summary stands once this run succeeds. Read this before the AI
# call rather than after it. The mark records the end of the story as this # call rather than after it. The mark records the end of the story as this
# pass saw it, and a turn that arrives during the call must not be counted # pass saw it, and a turn that arrives during the call must not be counted
# as read. # as read.
caught_up = history.newest(adventure) caught_up = history.newest(adventure)
if caught_up is None: if caught_up is None:
return return False
# Include the memories for the stretch that the summary has not read, which # Include the memories for the stretch that the summary has not read, which
# means every memory attached to a node past the anchor. The marks and the # means every memory attached to a node past the anchor. The marks and the
@@ -707,7 +885,25 @@ async def _update_story_summary(
block = history.after(adventure, anchor, uncovered) block = history.after(adventure, anchor, uncovered)
events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000) events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000)
current = adventure.story_summary.strip() # M6 corrective (review finding M6-F1). The previous summary this one
# builds on has to be a summary that is *valid where the story now stands*,
# not merely the last one written.
#
# Seeding from `adventure.story_summary` — a campaign-global column with no
# lineage — is what broke E03. After a divergence that column still held the
# abandoned line's prose, so the summariser was handed it and asked to
# update it. The row it produced was correctly anchored to the new branch
# and was therefore *reported* as lineage-safe, while its sentences
# described a story the reader had left. The row was anchored; the content
# was not.
#
# `summaries.current` answers the same question the context builder asks —
# which summary is eligible at the head — so the input and the output are
# now scoped by one rule. Where no eligible summary exists, the new line
# starts from nothing, which is the truthful starting point for a story
# that has not been summarised yet.
eligible = summaries.current(db, adventure)
current = eligible.text.strip() if eligible is not None else ""
# The summary is built from the memories, so it inherits their framing for # The summary is built from the memories, so it inherits their framing for
# free once they are named and third-person. It still gets the brief of its # free once they are named and third-person. It still gets the brief of its
# own, because the fallback above hands it raw second-person story text # own, because the fallback above hands it raw second-person story text
@@ -720,22 +916,31 @@ async def _update_story_summary(
) )
if brief: if brief:
user_prompt = f"{brief}\n\n{user_prompt}" user_prompt = f"{brief}\n\n{user_prompt}"
try:
text = await summary_provider(settings).complete( text = await summary_provider(settings).complete(
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600 SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
) )
except ProviderError:
return
if not text: if not text:
return return False
adventure.story_summary = text # M6: anchored to the story it summarizes rather than written into a single
# column. `caught_up` is the last node it covers, so the row is eligible on
# exactly the lineages that contain that node, and an Undo or a divergence
# makes it ineligible without deleting it (E03, `summaries` module).
summaries.record(
db, adventure, text,
node=caught_up,
source_start=anchor + 1 if anchor is not None else None,
trigger="interval",
model_name=(settings.summary_model or settings.model or ""),
)
cursors.SUMMARY.anchor_at(adventure, caught_up) cursors.SUMMARY.anchor_at(adventure, caught_up)
db.commit() db.commit()
return True
async def _embed_pending( async def _embed_pending(
adventure: models.Adventure, settings: models.Settings, db: Session adventure: models.Adventure, settings: models.Settings, db: Session
) -> None: ) -> int:
"""Embeds memories that have no vector. Returns how many (M6-F5)."""
# Use a query rather than walking `adventure.memories`. That walk ran on # Use a query rather than walking `adventure.memories`. That walk ran on
# every turn and loaded the whole bank's vectors in order to find the few # every turn and loaded the whole bank's vectors in order to find the few
# rows with none. # rows with none.
@@ -759,14 +964,15 @@ async def _embed_pending(
.all() .all()
) )
if not pending: if not pending:
return return 0
try: try:
new = await embedding_provider(settings).embed([m.text for m in pending]) new = await embedding_provider(settings).embed([m.text for m in pending])
except ProviderError: except ProviderError:
return return 0
for memory, vector in zip(pending, new): for memory, vector in zip(pending, new):
set_vector(memory, vector) set_vector(memory, vector)
db.commit() db.commit()
return len(pending)
def _evict_over_capacity( def _evict_over_capacity(
+55
View File
@@ -402,6 +402,15 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# so that every position an existing campaign can be restored to has a # so that every position an existing campaign can be restored to has a
# snapshot. See `_backfill_narrative_snapshots`. # snapshot. See `_backfill_narrative_snapshots`.
(88, "-- narrative snapshot backfill (data pass only)"), (88, "-- narrative snapshot backfill (data pass only)"),
# M6. `create_all` builds the two new tables — `summaries` and
# `derived_status` — as it did `state_events` and `checkpoints`. These are
# the columns it cannot add to a table that already exists, plus the data
# pass that moves an existing campaign's summary onto the lineage.
(89, "ALTER TABLE memories ADD COLUMN authority VARCHAR(20) "
"NOT NULL DEFAULT 'accepted_story'"),
(90, "CREATE INDEX IF NOT EXISTS ix_summaries_adventure "
"ON summaries (adventure_id, depth)"),
(91, "-- move the existing story summary onto the lineage (data pass only)"),
] ]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
@@ -416,6 +425,7 @@ CURSOR_ANCHOR_VERSION = 56
SIBLING_SPLIT_VERSION = 60 SIBLING_SPLIT_VERSION = 60
PARENT_BACKFILL_VERSION = 64 PARENT_BACKFILL_VERSION = 64
NARRATIVE_SNAPSHOT_VERSION = 88 NARRATIVE_SNAPSHOT_VERSION = 88
SUMMARY_LINEAGE_VERSION = 91
# An adventure with no actions has no tip. A value of -1 keeps the rule that the # An adventure with no actions has no tip. A value of -1 keeps the rule that the
# next node goes at `head_depth + 1` true without a special case. This matches # next node goes at `head_depth + 1` true without a special case. This matches
@@ -433,6 +443,49 @@ SNAPSHOT_BATCH = 50
BACKFILL_BATCH = 200 BACKFILL_BATCH = 200
def _backfill_summary_lineage(conn) -> None:
"""Moves each campaign's existing summary onto the lineage that produced it.
Before M6 the rolling summary lived in `adventures.story_summary` with a
separate `(branch_id, depth)` cursor recording how far it had read. The
cursor is exactly the coordinate the summary belongs at, so the existing
text becomes a `summaries` row anchored there and keeps working — including
becoming ineligible after an Undo or a divergence, which is what it could
not do before.
A campaign whose cursor never moved (`summary_cursor_branch_id` NULL) has a
summary somebody typed rather than one the pass produced. That anchors at
the head instead, which is where a hand-written summary belongs.
One statement, no row loop. The column is left in place: it is the Plot
panel's edit surface and the export bundle's field, and it now mirrors
whichever summary is eligible.
"""
conn.execute(text(
"""
INSERT INTO summaries (
adventure_id, text, branch_id, depth, source_start, source_end,
trigger, model_name, created_at
)
SELECT
a.id,
a.story_summary,
COALESCE(a.summary_cursor_branch_id, a.head_branch_id),
CASE WHEN a.summary_cursor_branch_id IS NULL
THEN a.head_depth ELSE a.summary_cursor_depth END,
NULL,
CASE WHEN a.summary_cursor_branch_id IS NULL
THEN a.head_depth ELSE a.summary_cursor_depth END,
CASE WHEN a.summary_cursor_branch_id IS NULL
THEN 'manual' ELSE 'interval' END,
'',
CURRENT_TIMESTAMP
FROM adventures a
WHERE TRIM(COALESCE(a.story_summary, '')) <> ''
"""
))
def _backfill_narrative_snapshots(conn) -> None: def _backfill_narrative_snapshots(conn) -> None:
"""Gives every pre-M5 action the empty narrative document as its outcome. """Gives every pre-M5 action the empty narrative document as its outcome.
@@ -1198,5 +1251,7 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
_backfill_parents(conn) _backfill_parents(conn)
if version == NARRATIVE_SNAPSHOT_VERSION: if version == NARRATIVE_SNAPSHOT_VERSION:
_backfill_narrative_snapshots(conn) _backfill_narrative_snapshots(conn)
if version == SUMMARY_LINEAGE_VERSION:
_backfill_summary_lineage(conn)
current = version current = version
_set_version(conn, current) _set_version(conn, current)
+104 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
String, Text, event, String, Text, UniqueConstraint, event,
) )
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
@@ -98,6 +98,19 @@ class Adventure(Base):
memory: Mapped[str] = mapped_column(Text, default="") memory: Mapped[str] = mapped_column(Text, default="")
authors_note: Mapped[str] = mapped_column(Text, default="") authors_note: Mapped[str] = mapped_column(Text, default="")
ai_instructions: Mapped[str] = mapped_column(Text, default="") ai_instructions: Mapped[str] = mapped_column(Text, default="")
# A convenience mirror of whichever summary is eligible at the current
# position, and **never** an input to anything authoritative (M6 corrective,
# review finding M6-F1).
#
# It exists because the Plot panel lets a reader read and edit the summary
# and the export bundle carries it. It is not a store: `summaries` rows are,
# and `summaries.current` decides which one the story is entitled to. This
# column has no lineage of its own, so anything that reads it as truth
# inherits whatever was written last, on whatever line — which is exactly
# how abandoned prose reached an active prompt before the correction.
#
# Kept in step by `summaries.record` when one is written and by
# `attempts.restore_state` when the head moves.
story_summary: Mapped[str] = mapped_column(Text, default="") story_summary: Mapped[str] = mapped_column(Text, default="")
# Phase 18: who the player is playing as. The AI never writes these — they # Phase 18: who the player is playing as. The AI never writes these — they
# are user-only, which is what lets them sit in the cached system block # are user-only, which is what lets them sit in the cached system block
@@ -198,6 +211,17 @@ class Adventure(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="Memory.id", order_by="Memory.id",
) )
# M6: the lineage-anchored generated summaries, newest last.
summaries: Mapped[list["Summary"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="Summary.id",
)
derived_status: Mapped[list["DerivedStatus"]] = relationship(
back_populates="adventure",
cascade="all, delete-orphan",
order_by="DerivedStatus.id",
)
class Branch(Base): class Branch(Base):
@@ -467,6 +491,14 @@ class Memory(Base):
# current. Readers need only the yes-or-no answer, and fetching six # current. Readers need only the yes-or-no answer, and fetching six
# kilobytes of vector to get it is too expensive. # kilobytes of vector to get it is too expensive.
embedded: Mapped[bool] = mapped_column(Boolean, default=False) embedded: Mapped[bool] = mapped_column(Boolean, default=False)
# M6: how much weight the narrator should give this memory
# (`CONTEXT-AND-MEMORY.md` §14). `accepted_story` is something the story
# actually established; `heuristic` is an interpretation of it. The
# application owns this classification — the extractor may hint, but
# `memorybank.classify_authority` decides — so a guess can never become
# canon merely by being written down. Authoritative state changes still go
# only through the M5 event path (ADR 013).
authority: Mapped[str] = mapped_column(String(20), default="accepted_story")
pinned: Mapped[bool] = mapped_column(Boolean, default=False) pinned: Mapped[bool] = mapped_column(Boolean, default=False)
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
use_count: Mapped[int] = mapped_column(Integer, default=0) use_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -476,6 +508,77 @@ class Memory(Base):
adventure: Mapped[Adventure] = relationship(back_populates="memories") adventure: Mapped[Adventure] = relationship(back_populates="memories")
class Summary(Base):
"""M6: one generated rolling summary, anchored to the story it summarizes.
The inherited design kept the summary in a single `adventures.story_summary`
column with a lineage cursor recording how far it had read. The cursor was
lineage-aware; the prose was not. After an Undo and a divergence the column
still held sentences describing the abandoned line, and the context builder
injected it unconditionally — the leak `STORY-BRANCH-SEMANTICS.md` §32 and
acceptance test E03 forbid.
A summary is therefore a row on a path, exactly as a `Memory` is, and it is
filtered through the same `lineage.Path.clause` chokepoint. `branch_id` and
`depth` are the coordinate it was written at; `source_start`/`source_end`
are the stretch of story it covers. A summary whose coordinate is not on the
active capped lineage is not eligible, and is never deleted for it — the
abandoned line keeps its own derived data (§11).
"""
__tablename__ = "summaries"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, default="")
# The coordinate this summary was written at: the last node it covers.
branch_id: Mapped[int | None] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE"), nullable=True
)
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
# The stretch of story it summarizes, as depths on `branch_id`.
source_start: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_end: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Why it was generated: "interval" for the automatic pass, "manual" when the
# reader wrote or edited it themselves.
trigger: Mapped[str] = mapped_column(String(20), default="interval")
model_name: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
adventure: Mapped[Adventure] = relationship(back_populates="summaries")
class DerivedStatus(Base):
"""M6: the outcome of one kind of background derived work, per campaign.
M2 shipped with the whole memory bank dead and the suite green: the
summariser and the embedder raised inside a fire-and-forget task, and
nothing recorded it (`BUILD-MILESTONES.md`, note from M2). Derived work is
allowed to fail — the accepted turn, the state and the head must all
survive it — but it is not allowed to fail *invisibly*.
One row per (adventure, kind), rewritten in place. This is deliberately not
a job queue: it records what happened last, so a reader can see that
memories stopped being written and why, and so a maintainer can retry.
"""
__tablename__ = "derived_status"
__table_args__ = (UniqueConstraint("adventure_id", "kind", name="uq_derived_kind"),)
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE"))
# "memory", "summary" or "embedding".
kind: Mapped[str] = mapped_column(String(20))
# "ok" (did work), "idle" (ran, nothing pending) or "failed".
status: Mapped[str] = mapped_column(String(20), default="ok")
detail: Mapped[str] = mapped_column(Text, default="")
failures: Mapped[int] = mapped_column(Integer, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
adventure: Mapped[Adventure] = relationship(back_populates="derived_status")
class StoryCard(Base): class StoryCard(Base):
"""Owned by either a scenario or an adventure (exactly one set).""" """Owned by either a scenario or an adventure (exactly one set)."""
+13 -2
View File
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import set_committed_value from sqlalchemy.orm.attributes import set_committed_value
from ... import ( from ... import (
attempts, head, images, limits, memorybank, models, schemas, tree, worldstate, attempts, head, images, limits, memorybank, models, schemas, summaries, tree,
worldstate,
) )
from ...database import get_db from ...database import get_db
@@ -294,8 +295,18 @@ def update_adventure(
db: Session = Depends(get_db), db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure), adventure: models.Adventure = Depends(current_adventure),
): ):
for field, value in payload.model_dump(exclude_unset=True).items(): fields = payload.model_dump(exclude_unset=True)
for field, value in fields.items():
setattr(adventure, field, value) setattr(adventure, field, value)
# M6: a summary the reader typed is still a summary, so it is anchored to
# the position they typed it at rather than left in a column with no
# lineage. Otherwise a hand-written summary would survive an Undo and a
# divergence that its generated equivalent correctly does not (E03).
if "story_summary" in fields:
typed = (fields["story_summary"] or "").strip()
held = summaries.current(db, adventure)
if typed and (held is None or held.text.strip() != typed):
summaries.record(db, adventure, typed, trigger="manual")
db.commit() db.commit()
return adventure return adventure
+47 -2
View File
@@ -7,8 +7,8 @@ returns the prompt a turn was actually generated from. Neither writes anything.
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ... import memorybank, models from ... import derived, memorybank, models, summaries
from ...context import build_context from ...context import ContextOverflow, build_context
from ...database import get_db from ...database import get_db
from ..settings import get_settings from ..settings import get_settings
@@ -24,10 +24,55 @@ async def dry_run_context(
"""Returns what the app would send to the AI if the player continued now.""" """Returns what the app would send to the AI if the player continued now."""
settings = get_settings(db, user) settings = get_settings(db, user)
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
try:
_, _, report = build_context(adventure, settings, memories) _, _, report = build_context(adventure, settings, memories)
except ContextOverflow as exc:
# M6: a dry run of a prompt that cannot be built is still an answer, and
# a more useful one than a 500. The reader opened this panel to find out
# what would be sent; "nothing, because the protected context does not
# fit, and here is by how much" is exactly that.
raise HTTPException(422, str(exc)) from exc
return report return report
@router.get("/{adventure_id}/derived")
def derived_status(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""M6: whether background memory, summary and embedding work is healthy.
The surface that makes a dead memory bank findable. M2 shipped with the
whole bank failing inside a fire-and-forget task and nothing anywhere said
so — not the UI, not a log a player would read, not a failing test
(`BUILD-MILESTONES.md`, note from M2). This endpoint is where that now
shows.
"""
# Resolved once, not once per row: which summary the current head is
# entitled to. Asking inside the comprehension would be one query per
# summary, which is the shape M5 spent a finding removing.
eligible = summaries.current(db, adventure)
eligible_id = eligible.id if eligible is not None else None
status = derived.report(db, adventure.id)
return {
"status": status,
"failing": [row["kind"] for row in status if row["status"] == "failed"],
"summaries": [
{
"id": row.id,
"branch_id": row.branch_id,
"depth": row.depth,
"trigger": row.trigger,
"model": row.model_name,
"eligible": row.id == eligible_id,
"created_at": row.created_at.isoformat() if row.created_at else None,
"preview": row.text[:200],
}
for row in summaries.all_for(db, adventure)
],
}
@router.get("/{adventure_id}/actions/{action_id}/context") @router.get("/{adventure_id}/actions/{action_id}/context")
def action_context( def action_context(
adventure_id: int, adventure_id: int,
+10 -1
View File
@@ -16,7 +16,7 @@ from ... import (
attempts, head, limits, memorybank, models, narrative, schemas, tree, attempts, head, limits, memorybank, models, narrative, schemas, tree,
worldstate, worldstate,
) )
from ...context import build_context, cursors from ...context import ContextOverflow, build_context, cursors
from ...database import get_db from ...database import get_db
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
from ...sse import SSE_HEADERS, sse, turn_error from ...sse import SSE_HEADERS, sse, turn_error
@@ -164,9 +164,18 @@ async def _generate_turn(
memories = await memorybank.retrieve_memories( memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id adventure, settings, update_stats=True, exclude_action_id=replacing_id
) )
try:
system_text, story_text, snapshot = build_context( system_text, story_text, snapshot = build_context(
adventure, settings, memories, exclude_action_id=replacing_id adventure, settings, memories, exclude_action_id=replacing_id
) )
except ContextOverflow as exc:
# M6: the protected context does not fit in the configured budget, so
# there is no prompt to send. This is a settings problem the reader can
# fix, and the message says how — reporting it as a failed turn keeps
# the story intact and tells them what to change, where building the
# prompt anyway would return a silently truncated reply.
yield turn_error(str(exc))
return
parts = PromptParts(system=system_text, story=story_text) parts = PromptParts(system=system_text, story=story_text)
+155
View File
@@ -0,0 +1,155 @@
"""M6: the rolling story summary, anchored to the story it summarizes.
A summary is compressed derived history. It is never the source of truth — the
retained transcript is (`CONTEXT-AND-MEMORY.md` §9) — and it is never allowed to
describe a story the reader is not on.
The inherited design kept one `adventures.story_summary` column and a lineage
cursor recording how far the summariser had read. The cursor was lineage-aware;
the prose it produced was not. After an Undo and a divergence the column still
held sentences about the abandoned line, and the context builder injected it
with no eligibility check at all — acceptance test E03, and measured failing
against the M5 baseline before this module existed.
The fix is not a new lineage system. A summary is a row with a coordinate, the
way a `Memory` already is, and it is filtered through the same
`lineage.Path.clause` chokepoint every other read of the story goes through. So:
eligible == its coordinate is on the active, head-capped lineage
which gives the four behaviours the milestone asks for, without a rule of its
own for any of them:
A -> B -> C -> D, summary covers A..C, head at D eligible
Undo to B not eligible
Redo to D eligible again
diverge from B onto X -> Y not eligible
Nothing is deleted when a line is abandoned. The abandoned line keeps its own
summaries, and they become eligible again if the reader returns to it.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import models
from .context import lineage
def record(
db: Session,
adventure: models.Adventure,
text: str,
*,
node: models.Action | None = None,
source_start: int | None = None,
trigger: str = "interval",
model_name: str = "",
) -> models.Summary:
"""Stores one summary at the coordinate the story has reached.
`node` is the last action the summary covers, which is where the row is
anchored. Without one the summary anchors at the head, which is what a
summary the reader typed themselves covers.
"""
branch_id = adventure.head_branch_id
depth = adventure.head_depth
if node is not None and node.depth is not None:
branch_id, depth = node.branch_id, node.depth
row = models.Summary(
adventure_id=adventure.id,
text=text.strip(),
branch_id=branch_id,
depth=depth,
source_start=source_start,
source_end=depth,
trigger=trigger,
model_name=model_name,
)
db.add(row)
mirror(adventure, row.text)
return row
def mirror(adventure: models.Adventure, text: str) -> None:
"""Points `adventures.story_summary` at the summary now in force.
That column is a reader-facing convenience — the Plot panel edits it, the
export bundle carries it — and nothing authoritative may read it. It has no
lineage, so it holds whatever was written last on whatever line, and the M6
review found the summariser seeding itself from exactly that: after a
divergence it was handed the abandoned line's prose and asked to update it
(finding M6-F1).
The fix was to seed generation from `current()` instead. This function keeps
the column honest as well, so what a reader sees in the Plot panel and what
an export carries is the summary the narrator is actually being given.
"""
adventure.story_summary = text or ""
def refresh_mirror(db: Session, adventure: models.Adventure) -> None:
"""Re-points the mirror after the head has moved.
Called from `attempts.restore_state`, which every Undo, Redo, take switch
and Save Point restore goes through. Without it the column would keep
showing a summary the story has moved away from.
"""
row = current(db, adventure)
mirror(adventure, row.text if row is not None else "")
def current(db: Session, adventure: models.Adventure) -> models.Summary | None:
"""The newest summary eligible for the position being read, or None.
Eligibility is the capped lineage clause and nothing else. Ordering by
depth then id takes the newest summary on the path, so a fresher summary
written on a shallower branch does not outrank the deep one it was
superseded by.
"""
return db.execute(
select(models.Summary)
.where(
models.Summary.adventure_id == adventure.id,
lineage.path_of(db, adventure).clause(models.Summary),
)
.order_by(models.Summary.depth.desc(), models.Summary.id.desc())
.limit(1)
).scalars().first()
def text_for_prompt(db: Session, adventure: models.Adventure) -> str:
"""The summary the narrator should be shown, or an empty string."""
row = current(db, adventure)
return row.text if row is not None and row.text.strip() else ""
def provenance(row: models.Summary | None) -> dict | None:
"""What the inspector shows about where a summary came from."""
if row is None:
return None
return {
"id": row.id,
"branch_id": row.branch_id,
"depth": row.depth,
"source_start": row.source_start,
"source_end": row.source_end,
"trigger": row.trigger,
"model": row.model_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def all_for(db: Session, adventure: models.Adventure) -> list[models.Summary]:
"""Every stored summary, eligible or not, newest first.
Abandoned summaries are retained rather than deleted, so this is how a
reader or a maintainer sees that they still exist.
"""
return list(db.execute(
select(models.Summary)
.where(models.Summary.adventure_id == adventure.id)
.order_by(models.Summary.id.desc())
).scalars().all())
+837
View File
@@ -0,0 +1,837 @@
"""M6: branch-safe context, summaries and long-term story memory.
The acceptance contract for this milestone is F01-F08 plus the E-series lineage
tests that own the memory and summary consequences of branching. Each test below
names the criterion it carries.
Two things are asserted throughout rather than assumed:
* **The assembled prompt, not the narration.** A model that fails to mention a
leaked memory is not evidence that the memory did not leak, so every leak test
reads the context the builder actually produced.
* **The lineage chokepoint, not a reimplementation.** Memories and summaries are
filtered by `lineage.Path.clause`, the same clause every read of the story
goes through. A test that walked the tree itself could pass while the product
leaked.
python -m pytest tests/test_context_memory.py -v
"""
import asyncio
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import auth, derived, limits, memorybank, models, summaries
from app.context import builder, lineage
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.providers import ProviderError
from app.routers import adventures
from fakes import ScriptedProvider, state_block
class StubEmbedder:
"""A deterministic embedder. Distinct texts get distinguishable vectors."""
def __init__(self):
self.calls = 0
async def embed(self, texts):
self.calls += 1
out = []
for text in texts:
lowered = text.lower()
out.append([
1.0,
1.0 if "ledger" in lowered or "flagstone" in lowered else 0.0,
1.0 if "chapel" in lowered else 0.0,
])
return out
class StubSummariser:
"""Stands in for the summariser so this file opens no sockets."""
async def complete(self, system, user, *, max_tokens=600):
return "A summary of what has happened so far."
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m6@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="enc:dummy", model="test-model",
embedding_model="embed-test", context_token_budget=4000,
max_output_tokens=400, memory_top_k=3,
))
adventure = models.Adventure(
user_id=user.id, title="M6", memory_bank_enabled=True, auto_summarize=True,
)
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()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
# Both derived providers are stubbed, not just the embedder (M6 review
# finding M6-F3). With only the embedder replaced, the post-turn pass built
# a real summariser against the default endpoint and every turn in this file
# opened a socket to localhost:11434 — slow, dependent on what happens to be
# listening, and the source of an abandoned-coroutine RuntimeWarning when
# the TestClient event loop closed under it. Tests that deliberately
# exercise real provider construction live in `test_provider_wiring.py`.
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubEmbedder())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubSummariser())
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
try:
yield test_client
finally:
app.dependency_overrides.clear()
memorybank._vector_cache.clear()
Base.metadata.drop_all(bind=engine)
# ----------------------------------------------------------------- helpers
def play(client, text, prose="The road bends onward past the treeline.", events=None):
ScriptedProvider.replies = [f"{prose}\n{state_block(events or [])}"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:300]
assert '"error"' not in r.text, r.text[:300]
def head_of(client):
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return adventure.head_branch_id, adventure.head_depth
def context_report(client) -> dict:
"""The prompt the app would send now, assembled through the real builder."""
r = client.get(f"/api/adventures/{client.adv_id}/context")
assert r.status_code == 200, r.text[:300]
return r.json()
def prompt_text(report: dict) -> str:
return "\n".join(s["text"] for s in report["sections"])
def plant_memory(client, text, *, authority=None, at_depth=None):
"""Attaches an embedded memory to a live node, as the real pass would."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
depth = adventure.head_depth if at_depth is None else at_depth
node = db.execute(
select(models.Action).where(
models.Action.adventure_id == client.adv_id,
lineage.path_of(db, adventure).uncapped().clause(models.Action),
models.Action.depth == depth,
)
).scalars().first()
assert node is not None, f"no live node at depth {depth}"
memory = models.Memory(
adventure_id=client.adv_id, text=text,
branch_id=node.branch_id, depth=node.depth,
source_start=node.depth, source_end=node.depth,
authority=authority or memorybank.classify_authority(text),
)
memorybank.set_vector(memory, asyncio.run(StubEmbedder().embed([text]))[0])
db.add(memory)
db.commit()
return memory.id
def eligible_memory_texts(client) -> list[str]:
"""What the retrieval filter would consider, through the real clause."""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
return list(db.execute(
select(models.Memory.text).where(
models.Memory.adventure_id == client.adv_id,
lineage.path_of(db, adventure).clause(models.Memory),
models.Memory.forgotten.is_(False),
)
).scalars().all())
# --------------------------------------------------------------------- F01
def test_f01_recent_turns_stay_in_the_prompt(client):
"""F01. The immediately preceding turns are what conversational coherence
is made of, so they have to actually be there."""
play(client, "ask Mara about the key", prose="Mara turns the silver key over.")
play(client, "wait for her answer", prose="'I found it at the chapel,' she says.")
story = prompt_text(context_report(client))
assert "Mara turns the silver key over." in story
assert "'I found it at the chapel,' she says." in story
assert "ask Mara about the key" in story
# --------------------------------------------------------------------- F02
def test_f02_an_old_clue_survives_outside_recent_history(client):
"""F02. A distinctive clue is planted, the story runs on past it, and the
clue comes back through memory rather than through the whole transcript."""
play(client, "search the floor",
prose="Aldric pries up the third flagstone and hides the ledger beneath it.")
plant_memory(client, "Aldric hid the ledger beneath the third flagstone.")
for i in range(22):
play(client, f"walk on {i}", prose=f"[{i}] " + "The road runs on. " * 60)
report = context_report(client)
story = prompt_text(report)
# It has fallen out of the verbatim history.
history_text = "\n".join(
s["text"] for s in report["sections"]
if s["label"] in ("history", "recent_history")
)
assert "third flagstone" not in history_text, (
"the fixture did not push the clue out of recent history"
)
# But it is still available to the narrator, through memory.
assert "third flagstone" in story
assert any("flagstone" in m["text"] for m in report["memories"]["used"])
# And not by sending the whole story.
assert report["history"]["included"] < report["history"]["total"]
# --------------------------------------------------------------------- F03
def test_f03_the_prompt_stays_bounded_as_the_story_grows(client):
"""F03. Input must not grow with the transcript."""
play(client, "begin", prose="The road bends. " * 40)
for i in range(6):
play(client, f"on {i}", prose=f"[{i}] " + "The road bends. " * 40)
short = context_report(client)
for i in range(24):
play(client, f"further {i}", prose=f"[{i}] " + "The road bends. " * 40)
long = context_report(client)
assert long["history"]["total"] > short["history"]["total"] * 2, "fixture too small"
budget = long["tokens"]["budget"]
assert long["tokens"]["total"] <= budget
# Four times the story must not be four times the prompt.
assert long["tokens"]["total"] < short["tokens"]["total"] * 2
# --------------------------------------------------------------------- F04
def test_f04_the_reply_budget_is_reserved(client):
"""F04. The configured reply length stays available whatever the story."""
for i in range(20):
play(client, f"on {i}", prose=f"[{i}] " + "The road bends. " * 40)
report = context_report(client)
with SessionLocal() as db:
settings = db.query(models.Settings).filter_by(user_id=client.user_id).first()
max_output = settings.max_output_tokens
assert report["tokens"]["output_reserve"] >= max_output
assert report["tokens"]["total"] + max_output <= report["tokens"]["budget"], (
"the assembled input left no room for the reply"
)
def test_f04_a_budget_too_small_for_the_reply_is_refused(client):
"""Section 9: fail clearly rather than build a prompt known to overflow."""
play(client, "begin")
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
settings = db.query(models.Settings).filter_by(user_id=client.user_id).first()
settings.context_token_budget = 200
settings.max_output_tokens = 4000
db.commit()
with pytest.raises(builder.ContextOverflow) as exc:
builder.build_context(adventure, settings)
# The message has to say what to change.
assert "context budget" in str(exc.value)
assert "reserved for the reply" in str(exc.value)
def test_f04_an_impossible_budget_fails_the_turn_without_losing_the_story(client):
"""The refusal reaches the reader as a failed turn, not a 500."""
play(client, "begin", prose="The lantern swings.")
with SessionLocal() as db:
settings = db.query(models.Settings).filter_by(user_id=client.user_id).first()
settings.context_token_budget = 200
settings.max_output_tokens = 4000
db.commit()
ScriptedProvider.replies = ["should never be reached"]
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": "carry on"})
assert r.status_code == 200
assert "context budget" in r.text
# The story that already existed is untouched.
actions = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
assert any("The lantern swings." in a["text"] for a in actions)
# --------------------------------------------------------------------- F05
def test_f05_the_inspector_shows_every_component_m6_owns(client):
"""F05, for the components this milestone owns."""
play(client, "begin", prose="Aldric sets the key down.",
events=[{"type": "create_entity", "entity": "aldric",
"entity_type": "character", "name": "Aldric"}])
plant_memory(client, "Aldric hid the ledger beneath the third flagstone.")
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
summaries.record(db, adventure, "The party reached the Crooked Lantern.")
db.commit()
play(client, "carry on")
report = context_report(client)
labels = {s["label"] for s in report["sections"]}
assert "narrator" in labels, "narrator/system rules"
assert "narrative_state" in labels, "current authoritative state"
assert "story_summary" in labels, "the summary used"
assert "used_memories" in labels, "retrieved memories"
assert "history" in labels, "recent history"
# Model and settings.
assert report["settings"]["model"] == "test-model"
assert report["settings"]["max_output_tokens"] == 400
# Token accounting, per component and in total.
assert all(isinstance(s["tokens"], int) for s in report["sections"])
for key in ("total", "budget", "output_reserve", "protected", "available_for_history"):
assert key in report["tokens"], key
# Summary provenance.
assert report["summary"]["depth"] is not None
# Derived-work health.
assert isinstance(report["derived"], list)
# --------------------------------------------------------------------- F06
def test_f06_a_retrieved_memory_is_traceable_to_its_source(client):
"""F06. "Where did this memory come from?" must be answerable."""
play(client, "search the floor", prose="Aldric hides the ledger.")
memory_id = plant_memory(client, "Aldric hid the ledger beneath the third flagstone.")
play(client, "carry on")
used = context_report(client)["memories"]["used"]
entry = next(m for m in used if m["id"] == memory_id)
assert entry["source"]["branch_id"] is not None
assert entry["source"]["depth"] is not None
assert entry["source"]["source_start"] is not None
# And the coordinate names a real node of this campaign's accepted history.
with SessionLocal() as db:
node = db.execute(
select(models.Action).where(
models.Action.adventure_id == client.adv_id,
models.Action.branch_id == entry["source"]["branch_id"],
models.Action.depth == entry["source"]["depth"],
)
).scalars().first()
assert node is not None, "the memory's provenance points at no action"
# --------------------------------------------------------------------- F07
def test_f07_a_heuristic_memory_is_labelled_and_is_not_state(client):
"""F07. An inference may be recalled; it may not become canon."""
play(client, "watch her", prose="Mara glances at the door.",
events=[{"type": "create_entity", "entity": "mara",
"entity_type": "character", "name": "Mara"}])
plant_memory(client, "Mara seemed nervous around Captain Vale.")
play(client, "carry on")
report = context_report(client)
used = report["memories"]["used"]
entry = next(m for m in used if "Captain Vale" in m["text"])
assert entry["authority"] == "heuristic"
story = prompt_text(report)
assert "[inferred]" in story, "the prompt does not mark the inference"
assert "interpretation, not established fact" in story
# And it did not become authoritative state.
document = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
facts = [f["predicate"] for f in document["facts"]]
assert not any("Vale" in f for f in facts), "a heuristic memory became a fact"
def test_the_application_classifies_authority_not_the_model(client):
"""The classifier is the application's, and it is inspectable."""
assert memorybank.classify_authority(
"Aldric promised Mara he would return before dawn.") == "accepted_story"
assert memorybank.classify_authority(
"Mara seemed uneasy when Captain Vale was mentioned.") == "heuristic"
# --------------------------------------------------------------------- F08
def test_f08_a_failing_memory_pass_keeps_the_story_and_is_visible(client):
"""F08. Derived work fails softly, and audibly."""
play(client, "begin", prose="The lantern swings.",
events=[{"type": "create_entity", "entity": "aldric",
"entity_type": "character", "name": "Aldric"}])
before_state = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
class Broken:
async def complete(self, *a, **k):
raise ProviderError("the summariser is unreachable")
async def embed(self, texts):
raise ProviderError("the embedder is unreachable")
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
# Enough uncovered story that the memory pass is genuinely due.
for depth in range(20):
db.add(models.Action(adventure_id=adventure.id, type="do",
text=f"filler {depth}"))
db.commit()
# Read the head *after* the fixture's own writes, so what this test measures
# is the effect of the failing derived pass and nothing else.
before_head = head_of(client)
import app.memorybank as mb
real_summary, real_embed = mb.summary_provider, mb.embedding_provider
mb.summary_provider = lambda s: Broken()
mb.embedding_provider = lambda s: Broken()
try:
asyncio.run(mb.run_post_turn(client.adv_id))
finally:
mb.summary_provider, mb.embedding_provider = real_summary, real_embed
# The accepted story, its state and the head all survived.
actions = client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
assert any("The lantern swings." in a["text"] for a in actions)
after_state = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
assert after_state["entities"].keys() == before_state["entities"].keys()
assert head_of(client) == before_head
# The failure is findable.
status = client.get(f"/api/adventures/{client.adv_id}/derived").json()
assert "memory" in status["failing"], status
detail = next(r for r in status["status"] if r["kind"] == "memory")
assert "unreachable" in detail["detail"]
assert detail["failures"] >= 1
# And the story continues.
play(client, "carry on", prose="The door opens.")
assert any("The door opens." in a["text"]
for a in client.get(f"/api/adventures/{client.adv_id}").json()["actions"])
def test_f08_a_recovered_pass_clears_the_failure(client):
"""Derived work can be retried: the next healthy run clears the record."""
with SessionLocal() as db:
derived.failed(db, client.adv_id, derived.SUMMARY,
ProviderError("the summariser is unreachable"))
db.commit()
assert client.get(f"/api/adventures/{client.adv_id}/derived").json()["failing"] \
== ["summary"]
with SessionLocal() as db:
derived.succeeded(db, client.adv_id, derived.SUMMARY)
db.commit()
status = client.get(f"/api/adventures/{client.adv_id}/derived").json()
assert status["failing"] == []
row = next(r for r in status["status"] if r["kind"] == "summary")
assert row["status"] == "ok" and row["failures"] == 0
# ------------------------------------------------------- E02 / E03 lineage
#
# The memory half of this was already correct at the M5 baseline: memories carry
# a `(branch_id, depth)` coordinate and retrieval filters them through the
# capped lineage. These tests pin that behaviour so a later change cannot lose
# it. The summary half was not: before M6 the rolling summary was one column
# with no coordinate, and it leaked across a divergence. That is what
# `app/summaries.py` fixes, and what E03 below measures.
SECRET_A = "Aldric hid the ledger beneath the third flagstone."
SECRET_B = "The party swore an oath in the drowned chapel."
def test_e02_the_ten_step_memory_negative_control(client):
"""E02, exactly as the milestone brief numbers it."""
# 1-2. Establish the fact and let a memory be made from it.
play(client, "search the floor", prose="Aldric pries up the flagstone.")
plant_memory(client, SECRET_A)
# 3. Retrievable on that valid line.
assert SECRET_A in eligible_memory_texts(client)
assert SECRET_A in prompt_text(context_report(client))
# 4-5. Undo to before it: no longer eligible.
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
assert SECRET_A not in eligible_memory_texts(client)
assert SECRET_A not in prompt_text(context_report(client))
# 6-7. Redo: eligible again, and no re-embedding was needed.
client.post(f"/api/adventures/{client.adv_id}/redo")
client.post(f"/api/adventures/{client.adv_id}/redo")
assert SECRET_A in eligible_memory_texts(client)
with SessionLocal() as db:
assert db.execute(
select(models.Memory.embedded).where(
models.Memory.adventure_id == client.adv_id)
).scalars().first() is True, "the memory was re-embedded rather than reused"
# 8-9. Undo again and diverge onto a new continuation.
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
play(client, "take the other road", prose="A different road opens.")
# 10. Still stored, never in the active prompt.
with SessionLocal() as db:
assert db.query(models.Memory).filter_by(adventure_id=client.adv_id).count() == 1
assert SECRET_A not in eligible_memory_texts(client)
assert SECRET_A not in prompt_text(context_report(client))
def test_e02_the_same_control_through_a_save_point_restore(client):
"""E02 again, reached by restoring a Save Point rather than by Undo."""
play(client, "begin", prose="The lantern swings.")
r = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
json={"name": "Before the ledger"})
assert r.status_code in (200, 201), r.text[:200]
save_point = r.json()
play(client, "search the floor", prose="Aldric pries up the flagstone.")
plant_memory(client, SECRET_A)
assert SECRET_A in prompt_text(context_report(client))
r = client.post(
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
assert r.status_code == 200, r.text[:200]
assert SECRET_A not in eligible_memory_texts(client)
assert SECRET_A not in prompt_text(context_report(client))
# Diverging from the restored position keeps it out for good.
play(client, "a different road", prose="A different road opens.")
assert SECRET_A not in prompt_text(context_report(client))
with SessionLocal() as db:
assert db.query(models.Memory).filter_by(adventure_id=client.adv_id).count() == 1
def test_e03_an_abandoned_summary_is_retained_but_never_used(client):
"""E03. The failure this milestone fixes, measured in the prompt.
Before M6 the summary was a single column with a lineage cursor but no
lineage of its own, and the builder injected it unconditionally. Undo plus a
divergence therefore left the narrator reading sentences about a story the
reader was no longer on.
"""
play(client, "begin", prose="The lantern swings.")
play(client, "go to the chapel", prose="The chapel door gives.")
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
summaries.record(db, adventure, SECRET_B, trigger="interval",
model_name="test-model")
db.commit()
# Eligible on the line that produced it.
assert SECRET_B in prompt_text(context_report(client))
assert context_report(client)["summary"]["trigger"] == "interval"
# Undo before the summarized stretch, then diverge.
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
play(client, "take the other road", prose="A different road opens.")
report = context_report(client)
assert SECRET_B not in prompt_text(report), "an abandoned summary reached the prompt"
assert report["summary"] is None or SECRET_B not in report["summary"].get("preview", "")
# Retained, not deleted — and visible as retained.
status = client.get(f"/api/adventures/{client.adv_id}/derived").json()
stored = [row for row in status["summaries"] if SECRET_B in row["preview"]]
assert stored, "the abandoned summary was deleted rather than retained"
assert stored[0]["eligible"] is False
def test_e03_a_summary_becomes_eligible_again_on_redo(client):
"""The negative control needs its positive half: Redo restores the line, so
the summary written on it is usable again."""
play(client, "begin", prose="The lantern swings.")
play(client, "go to the chapel", prose="The chapel door gives.")
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
summaries.record(db, adventure, SECRET_B)
db.commit()
assert SECRET_B in prompt_text(context_report(client))
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
assert SECRET_B not in prompt_text(context_report(client))
client.post(f"/api/adventures/{client.adv_id}/redo")
client.post(f"/api/adventures/{client.adv_id}/redo")
assert SECRET_B in prompt_text(context_report(client))
def test_a_summary_the_reader_typed_is_anchored_too(client):
"""A hand-written summary is still a summary. It would otherwise survive a
divergence that its generated equivalent correctly does not."""
play(client, "begin", prose="The lantern swings.")
play(client, "go to the chapel", prose="The chapel door gives.")
r = client.patch(f"/api/adventures/{client.adv_id}",
json={"story_summary": SECRET_B})
assert r.status_code == 200, r.text[:200]
assert SECRET_B in prompt_text(context_report(client))
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
play(client, "the other road", prose="A different road opens.")
assert SECRET_B not in prompt_text(context_report(client))
def test_e01_and_e04_state_and_scene_are_unchanged_by_m6(client):
"""M5's lineage behaviour must not regress while context selection changes."""
play(client, "establish", prose="Mara arrives.", events=[
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"}])
play(client, "she learns", prose="Mara learns the code.", events=[
{"type": "add_fact", "subject": "mara", "predicate": "knows the vault code",
"fact_id": "vault"}])
document = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
assert "knows the vault code" in [f["predicate"] for f in document["facts"]]
client.post(f"/api/adventures/{client.adv_id}/undo")
client.post(f"/api/adventures/{client.adv_id}/undo")
play(client, "a different road", prose="A different road opens.")
document = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
assert "knows the vault code" not in [f["predicate"] for f in document["facts"]]
assert "vault code" not in prompt_text(context_report(client))
# ------------------------------------------- authority conflicts (section 8)
def test_a_memory_cannot_outrank_a_manual_correction(client):
"""Section 8. A withdrawn assertion may survive as history; it may not be
presented as current truth, whatever a memory says about it."""
play(client, "establish", prose="Mara arrives.", events=[
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"}])
play(client, "she learns", prose="Mara learns where the key was found.", events=[
{"type": "add_fact", "subject": "mara",
"predicate": "knows where the key was found", "fact_id": "mara-knows"}])
# A memory that records the same thing, written before the correction.
plant_memory(client, "Mara knows where the key was found.")
r = client.post(f"/api/adventures/{client.adv_id}/state/corrections", json={
"events": [{"type": "invalidate_fact", "fact_id": "mara-knows",
"reason": "Mara never learned where the silver key was found."}],
"note": "Mara never learned where the silver key was found.",
})
assert r.status_code in (200, 201), r.text[:200]
report = context_report(client)
sections = {s["label"]: s["text"] for s in report["sections"]}
# The authoritative state says it is withdrawn, in the prompt itself.
assert "No longer true" in sections["narrative_state"]
assert "Mara never learned" in sections["narrative_state"]
# The state section does not carry it among the facts that stand.
established = sections["narrative_state"].split("No longer true")[0]
assert "knows where the key was found" not in established
# The memory is subordinate: it is not state, and it is not canon.
document = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
active = [f["predicate"] for f in document["facts"]
if f.get("status") != "invalidated"]
assert "knows where the key was found" not in active
# ---------------------------------------------------- derived rebuildability
def test_derived_data_can_be_deleted_and_rebuilt(client):
"""Section 16. Authoritative history must not depend on derived rows."""
play(client, "begin", prose="The lantern swings.", events=[
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"}])
plant_memory(client, SECRET_A)
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
summaries.record(db, adventure, SECRET_B)
db.commit()
before_actions = [a["text"] for a in
client.get(f"/api/adventures/{client.adv_id}").json()["actions"]]
before_state = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
before_head = head_of(client)
# Remove every derived row.
with SessionLocal() as db:
db.query(models.Memory).filter_by(adventure_id=client.adv_id).delete()
db.query(models.Summary).filter_by(adventure_id=client.adv_id).delete()
db.commit()
after_actions = [a["text"] for a in
client.get(f"/api/adventures/{client.adv_id}").json()["actions"]]
after_state = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
assert after_actions == before_actions, "deleting derived data changed the transcript"
assert after_state == before_state, "deleting derived data changed the state"
assert head_of(client) == before_head
# The story still plays with no derived data at all.
play(client, "carry on", prose="The door opens.")
# And derived data can be written again.
plant_memory(client, SECRET_A)
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
summaries.record(db, adventure, SECRET_B)
db.commit()
assert SECRET_A in prompt_text(context_report(client))
assert SECRET_B in prompt_text(context_report(client))
# ------------------------------------------- E03 regenerated after divergence
#
# M6 review finding M6-F1. The original E03 test proved only that the *old*
# summary row becomes ineligible after a divergence, and passed while the defect
# was live: the summariser seeded itself from `adventures.story_summary`, a
# campaign-global mirror with no lineage, so the summary it generated on the new
# line inherited the abandoned line's prose. The row was correctly anchored; its
# contents were not.
#
# The regression below plays far enough on the new line to force a *new* summary
# to be generated, which is the step that was missing.
E03_SENTINEL = "ABANDONED-CHAPEL-OATH-9930"
class CarryingSummariser:
"""A summariser that behaves like a real one.
It carries the summary it was given forward and folds in the new events, so
"did abandoned content reach this summary?" has an exact answer. The
per-block memory prompt is answered separately, echoing the sentinel only
for blocks that genuinely contain it.
"""
def __init__(self):
self.summary_seeds = []
async def complete(self, system, user, *, max_tokens=600):
if "Current story summary:" not in user:
return f"MEM[{E03_SENTINEL}]" if E03_SENTINEL in user else "MEM[dry road]"
current = user.split("Current story summary:\n", 1)[1].split("\n\nNew events")[0]
events = user.split("New events since the last update:\n", 1)[1].split(
"\n\nUpdated summary:")[0]
self.summary_seeds.append(current.strip())
carried = "" if current.strip() == "(none yet)" else current.strip() + " "
return (carried + events.strip().replace("\n", " "))[:1500]
async def embed(self, texts):
return [[1.0, 0.0, 0.0] for _ in texts]
def test_e03_a_summary_generated_after_divergence_carries_no_abandoned_content(client):
"""M6-F1. The failure the original E03 test could not see.
Every step of the review's reproduction, in order, with the positive control
first — a summary that does not exist proves nothing about what it omits.
"""
summariser = CarryingSummariser()
import app.memorybank as mb
real_summary, real_embed = mb.summary_provider, mb.embedding_provider
mb.summary_provider = lambda s: summariser
mb.embedding_provider = lambda s: summariser
try:
# 1-2. Path A, long enough to generate a summary, with the sentinel on it.
for i in range(20):
play(client, f"a{i}", prose=f"They swear the {E03_SENTINEL}. [{i}]")
asyncio.run(mb.run_post_turn(client.adv_id))
# 3. POSITIVE CONTROL: the sentinel really is in the path-A summary.
report_a = context_report(client)
summary_a = next((s["text"] for s in report_a["sections"]
if s["label"] == "story_summary"), "")
assert summary_a, "no summary was generated on path A; the rest proves nothing"
assert E03_SENTINEL in summary_a, "the fixture did not put the sentinel in the summary"
assert E03_SENTINEL in prompt_text(report_a)
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
path_a_summary_id = summaries.current(db, adventure).id
# 4. Move the head below every turn that mentions the sentinel.
while head_of(client)[1] > 0:
if client.post(f"/api/adventures/{client.adv_id}/undo").status_code != 200:
break
# 5-6. Diverge, and play far enough that a NEW summary is generated.
# Seeds recorded from here on are the ones that matter: on path A the
# summariser is *supposed* to be seeded with the sentinel, because the
# sentinel is on path A.
summariser.summary_seeds.clear()
for i in range(20):
play(client, f"b{i}", prose=f"A dry road, nothing sworn. [{i}]")
asyncio.run(mb.run_post_turn(client.adv_id))
report_b = context_report(client)
summary_b_row = report_b["summary"]
summary_b = next((s["text"] for s in report_b["sections"]
if s["label"] == "story_summary"), "")
# 7. A new summary really was generated on the new line.
assert summary_b_row is not None, "no summary is eligible on path B"
assert summary_b_row["id"] != path_a_summary_id, (
"path B reused path A's summary row rather than generating one"
)
# 8. No path-A story is on path B's lineage, so anything from it is a leak.
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
carried_over = db.query(models.Action).filter(
models.Action.adventure_id == client.adv_id,
lineage.path_of(db, adventure).clause(models.Action),
models.Action.text.like(f"%{E03_SENTINEL}%"),
).count()
assert carried_over == 0, "the fixture left path-A story on path B's lineage"
# 9-10. The sentinel is in neither the new summary nor the whole prompt.
assert E03_SENTINEL not in summary_b, (
"the summary generated on path B carries the abandoned line's content"
)
assert E03_SENTINEL not in prompt_text(report_b), (
"abandoned content reached the active narrator prompt"
)
# And it was never even *offered* the abandoned prose: the fix is at the
# input, not a filter over the output.
assert summariser.summary_seeds, "no summary was generated on path B"
assert not any(E03_SENTINEL in seed for seed in summariser.summary_seeds), (
"the summariser was seeded with content from the abandoned line"
)
# 11. The old summary is retained, and reported as retained-but-ineligible.
listing = client.get(f"/api/adventures/{client.adv_id}/derived").json()
old = [row for row in listing["summaries"] if row["id"] == path_a_summary_id]
assert old, "the abandoned summary row was deleted rather than retained"
assert old[0]["eligible"] is False
finally:
mb.summary_provider, mb.embedding_provider = real_summary, real_embed
+202
View File
@@ -0,0 +1,202 @@
"""M6: the read paths this milestone touches must not grow a query per row.
M5 spent a review finding on an N+1 in the action list. M6 adds three things
that could each reintroduce one — a memory's provenance, a summary's source
coordinates, and the derived-work status — so each is measured here rather than
argued about.
The assertions are on *growth*, not on an exact count. A fixed number would
break on any unrelated query and teach the next person to raise the number; what
matters is that doubling the rows does not double the queries.
python -m pytest tests/test_context_performance.py -v
"""
import asyncio
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from sqlalchemy import event
from app import auth, limits, memorybank, models, summaries
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider, state_block
class StubEmbedder:
async def embed(self, texts):
return [[1.0, 0.0, 0.0] for _ in texts]
@pytest.fixture()
def sql_log():
statements: list[str] = []
def record(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
event.listen(engine, "before_cursor_execute", record)
try:
yield statements
finally:
event.remove(engine, "before_cursor_execute", record)
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="perf@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="enc:dummy", model="test-model",
embedding_model="embed-test", context_token_budget=8000,
max_output_tokens=400, memory_top_k=5,
))
adventure = models.Adventure(
user_id=user.id, title="Perf", memory_bank_enabled=True, auto_summarize=True,
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(adventure_id=adventure.id, type="start", text="A road."))
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: StubEmbedder())
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
try:
yield test_client
finally:
app.dependency_overrides.clear()
memorybank._vector_cache.clear()
Base.metadata.drop_all(bind=engine)
def _grow(client, *, turns, memories, summary_rows):
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(turns):
db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do",
text=f"[{i}] The road bends onward. " * 6))
db.commit()
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(memories):
memory = models.Memory(
adventure_id=adventure.id, text=f"Memory {i}: something happened.",
branch_id=adventure.head_branch_id, depth=adventure.head_depth,
source_start=0, source_end=adventure.head_depth,
)
memorybank.set_vector(memory, [1.0, 0.0, 0.0])
db.add(memory)
for i in range(summary_rows):
summaries.record(db, adventure, f"Summary {i}.")
db.commit()
def _count(sql_log, client) -> int:
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}/context")
assert r.status_code == 200, r.text[:200]
return len(sql_log)
def test_assembling_context_does_not_cost_a_query_per_memory(client, sql_log):
"""A memory's provenance is fetched in the same read as its text, so more
memories must not mean more queries."""
_grow(client, turns=10, memories=5, summary_rows=1)
small = _count(sql_log, client)
_grow(client, turns=0, memories=25, summary_rows=0)
large = _count(sql_log, client)
assert large <= small + 2, (
f"{small} queries with 5 memories, {large} with 30 — "
"the context read is paying per memory"
)
def test_assembling_context_does_not_cost_a_query_per_summary(client, sql_log):
"""Only the eligible summary is read, however many are retained."""
_grow(client, turns=10, memories=2, summary_rows=2)
small = _count(sql_log, client)
_grow(client, turns=0, memories=0, summary_rows=30)
large = _count(sql_log, client)
assert large <= small + 2, (
f"{small} queries with 2 summaries, {large} with 32 — "
"the context read is paying per summary"
)
def test_assembling_context_does_not_cost_a_query_per_turn(client, sql_log):
"""The history window is one read, not one per action."""
_grow(client, turns=10, memories=2, summary_rows=1)
small = _count(sql_log, client)
_grow(client, turns=60, memories=0, summary_rows=0)
large = _count(sql_log, client)
assert large <= small + 2, (
f"{small} queries at 10 turns, {large} at 70 — "
"the context read is paying per turn"
)
def test_the_derived_status_endpoint_does_not_pay_per_summary(client, sql_log):
"""The listing resolves the eligible summary once, not once per row."""
_grow(client, turns=6, memories=1, summary_rows=3)
sql_log.clear()
assert client.get(f"/api/adventures/{client.adv_id}/derived").status_code == 200
small = len(sql_log)
_grow(client, turns=0, memories=0, summary_rows=30)
sql_log.clear()
assert client.get(f"/api/adventures/{client.adv_id}/derived").status_code == 200
large = len(sql_log)
assert large <= small + 1, (
f"{small} queries with 3 summaries, {large} with 33"
)
def test_the_context_size_stops_growing_once_the_budget_is_reached(client):
"""The companion to the query counts: more story, not more prompt.
Measured from a story that already fills the budget. Comparing a short story
to a long one only shows that the prompt grew, which it is supposed to do
until it reaches the ceiling; what F03 is about is that it stops there.
"""
_grow(client, turns=140, memories=3, summary_rows=1)
filled = client.get(f"/api/adventures/{client.adv_id}/context").json()
budget = filled["tokens"]["budget"]
assert filled["tokens"]["total"] > budget * 0.5, (
"the fixture never filled the budget, so this proves nothing"
)
_grow(client, turns=280, memories=0, summary_rows=0)
doubled = client.get(f"/api/adventures/{client.adv_id}/context").json()
assert doubled["history"]["total"] > filled["history"]["total"] * 2, "fixture too small"
assert doubled["tokens"]["total"] <= budget
# Three times the story, and the prompt does not move.
assert doubled["tokens"]["total"] <= filled["tokens"]["total"] + 50, (
f"{filled['tokens']['total']} -> {doubled['tokens']['total']} tokens "
f"while the story went from {filled['history']['total']} to "
f"{doubled['history']['total']} actions"
)
# And it is bounded by the budget rather than by the length of the story.
assert doubled["history"]["included"] < doubled["history"]["total"]
+238
View File
@@ -0,0 +1,238 @@
"""M6 section 13: context assembly and derived work against a real model.
The M6 equivalent of `test_narrative_realistic.py`, and it exists for the same
reason: memory and summary extraction can look correct against a tiny synthetic
prompt and behave differently under a full application context — a real narrator
instruction, real authoritative state, enough recent story to exercise
budgeting, a summary, and several memories.
**What is asserted, and what is not.** These tests do not assert that the model
writes a good summary or picks the right memory. No test can, and a threshold
would fail when a model is swapped rather than when the code breaks. They assert
that the *application* stays correct around whatever the model produces:
* the prompt stays inside its budget and keeps the reply reserve;
* a summary the model generates is anchored to the story it covers;
* a failure is recorded rather than swallowed;
* nothing from an abandoned line reaches the prompt.
Model behaviour is recorded as evidence and printed, not asserted.
## Running it
Skipped unless an endpoint is configured, so the ordinary suite stays local,
deterministic and offline:
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
AIDND_TEST_EMBED_MODEL=nomic-embed-text \\
python -m pytest tests/test_context_realistic.py -v -s
The endpoint is read from the environment and never written down here, and the
same endpoint policy the rest of the product enforces applies: loopback or a
trusted-LAN address, TLS verified, no cloud.
"""
import asyncio
import json
import os
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models, summaries
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider, state_block
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
EMBED_MODEL = os.environ.get("AIDND_TEST_EMBED_MODEL", "nomic-embed-text")
pytestmark = pytest.mark.skipif(
not (ENDPOINT and MODEL),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
CANON = {
"rules": ["The Crooked Lantern is the only inn in the valley."],
"forbidden": ["No character may use magic."],
}
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="m6live@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, api_key="enc:dummy", endpoint_url=ENDPOINT, model=MODEL,
summary_model=MODEL, embedding_model=EMBED_MODEL,
context_token_budget=8192, max_output_tokens=700, memory_top_k=4,
model_timeout_seconds=600,
))
adventure = models.Adventure(
user_id=user.id, title="The Crooked Lantern",
memory_bank_enabled=True, auto_summarize=True, campaign_canon=CANON,
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(adventure_id=adventure.id, type="start",
text="Rain hammers the road outside the Crooked Lantern."))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
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
try:
yield test_client
finally:
app.dependency_overrides.clear()
memorybank._vector_cache.clear()
Base.metadata.drop_all(bind=engine)
def play_scripted(client, text, prose, events=None):
"""A turn with a known outcome, so the fixture is deterministic."""
real = adventures.turns.OpenAICompatibleProvider
adventures.turns.OpenAICompatibleProvider = ScriptedProvider
ScriptedProvider.replies = [f"{prose}\n{state_block(events or [])}"]
try:
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert r.status_code == 200, r.text[:300]
finally:
adventures.turns.OpenAICompatibleProvider = real
def context(client) -> dict:
r = client.get(f"/api/adventures/{client.adv_id}/context")
assert r.status_code == 200, r.text[:300]
return r.json()
def test_a_real_summary_is_generated_and_anchored(client):
"""The summariser runs against the real model, and what it writes is
anchored to the story it read rather than to a column."""
play_scripted(client, "step inside", "Aldric shakes the rain from his coat.", [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"},
{"type": "create_entity", "entity": "mara", "entity_type": "character",
"name": "Mara"},
])
for i in range(18):
play_scripted(client, f"talk on {i}",
f"Mara pours another measure and tells him about the road north. "
f"The lantern gutters. [{i}]")
asyncio.run(memorybank.run_post_turn(client.adv_id))
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
rows = summaries.all_for(db, adventure)
eligible = summaries.current(db, adventure)
status = {r["kind"]: r["status"] for r in
__import__("app.derived", fromlist=["report"]).report(db, client.adv_id)}
print(json.dumps({
"model": MODEL, "embedding_model": EMBED_MODEL,
"summaries_written": len(rows),
"derived_status": status,
"summary_preview": (eligible.text[:300] if eligible else None),
}, indent=2, sort_keys=True))
assert status.get("summary") == "ok", f"the summariser failed: {status}"
assert rows, "no summary was written"
assert eligible is not None
# Anchored, not floating: it names the stretch of story it covers.
assert eligible.depth is not None
assert eligible.branch_id is not None
assert eligible.model_name == MODEL
# And it reaches the prompt.
assert eligible.text[:40] in "\n".join(s["text"] for s in context(client)["sections"])
def test_the_prompt_stays_bounded_and_reserves_the_reply_under_real_context(client):
"""Budgeting, measured on a realistic prompt rather than a synthetic one."""
play_scripted(client, "step inside", "Aldric shakes the rain from his coat.", [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"},
])
for i in range(40):
play_scripted(client, f"on {i}",
f"[{i}] " + "The lantern swings and the rain keeps on. " * 20)
asyncio.run(memorybank.run_post_turn(client.adv_id))
report = context(client)
print(json.dumps({
"model": MODEL,
"budget": report["tokens"]["budget"],
"input_tokens": report["tokens"]["total"],
"output_reserve": report["tokens"]["output_reserve"],
"protected": report["tokens"]["protected"],
"available_for_history": report["tokens"]["available_for_history"],
"actions_included": report["history"]["included"],
"actions_total": report["history"]["total"],
"memories_used": len(report["memories"]["used"]) if report["memories"] else 0,
}, indent=2, sort_keys=True))
assert report["tokens"]["total"] <= report["tokens"]["budget"]
assert report["tokens"]["total"] + 700 <= report["tokens"]["budget"], (
"the real prompt left no room for the configured reply"
)
assert report["history"]["included"] < report["history"]["total"], (
"the whole transcript was sent"
)
def test_a_real_turn_still_generates_with_memory_and_summary_present(client):
"""The end-to-end shape: a real narrator turn on a campaign that has a
generated summary, retrieved memories and authoritative state."""
play_scripted(client, "step inside", "Aldric shakes the rain from his coat.", [
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
"name": "Aldric"},
])
for i in range(18):
play_scripted(client, f"talk {i}",
f"They talk of the road north while the fire burns down. [{i}]")
asyncio.run(memorybank.run_post_turn(client.adv_id))
# A real turn, through the real provider.
r = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": "ask Mara what lies north"})
assert r.status_code == 200, r.text[:300]
assert '"error"' not in r.text, r.text[:400]
with SessionLocal() as db:
action = (db.query(models.Action)
.filter_by(adventure_id=client.adv_id, type="ai")
.order_by(models.Action.id.desc()).first())
snapshot = action.context_snapshot
text = action.text
labels = [s["label"] for s in snapshot["sections"]]
print(json.dumps({
"model": MODEL,
"sections": labels,
"input_tokens": snapshot["tokens"]["total"],
"output_reserve": snapshot["tokens"]["output_reserve"],
"reply_chars": len(text),
}, indent=2, sort_keys=True))
assert "narrator" in labels
assert "history" in labels
# The reply is a story, not protocol.
assert "```state" not in text
assert '"events"' not in text
+6 -1
View File
@@ -108,7 +108,12 @@ def actions_loaded():
# ------------------------------------------------------- the prompt is equal # ------------------------------------------------------- the prompt is equal
@pytest.mark.parametrize("budget", [1024, 4096, 8192, 16384, 65536]) # The smallest budget here is the tightest one this fixture can still build a
# prompt for. M6 reserves the reply out of the context budget, so 1024 with an
# 800-token reply and 750 tokens of protected prompt is no longer a
# configuration that produces a prompt — it raises `ContextOverflow`, which
# `test_a_budget_too_small_for_the_reply_is_refused` covers.
@pytest.mark.parametrize("budget", [2048, 4096, 8192, 16384, 65536])
def test_window_builds_the_same_prompt_as_the_whole_story(story, budget, monkeypatch): def test_window_builds_the_same_prompt_as_the_whole_story(story, budget, monkeypatch):
db, adventure, settings = story db, adventure, settings = story
settings.context_token_budget = budget settings.context_token_budget = budget
+6 -1
View File
@@ -216,8 +216,13 @@ def test_prompt_stays_inside_the_budget_on_a_long_story(story):
db.expire_all() db.expire_all()
adventure = db.get(models.Adventure, adventure.id) adventure = db.get(models.Adventure, adventure.id)
# A large reply budget, so the length hint is long enough for a missing
# reservation to show. M6 reserves the reply out of the context budget, so
# the budget has to be large enough to hold both — 2048 with a 2400-token
# reply is a configuration that cannot produce a prompt at all, and now
# says so rather than silently overflowing.
settings.max_output_tokens = 2400 settings.max_output_tokens = 2400
settings.context_token_budget = 2048 settings.context_token_budget = 8192
_, _, report = builder.build_context(adventure, settings) _, _, report = builder.build_context(adventure, settings)
assert report["history"]["included"] < 120, "budget was never actually filled" assert report["history"]["included"] < 120, "budget was never actually filled"
+50 -5
View File
@@ -20,6 +20,7 @@ exactly as `test_branch_clause.py` builds it.
python -m pytest tests/test_memory_nodes.py -v python -m pytest tests/test_memory_nodes.py -v
""" """
import asyncio import asyncio
import math
import pytest import pytest
@@ -138,11 +139,26 @@ def forked():
nodes[f"C{depth}"] = add_node(db, adventure, c, depth, "C") nodes[f"C{depth}"] = add_node(db, adventure, c, depth, "C")
db.flush() db.flush()
# Distinct vectors, equally similar to the query.
#
# These tests are about *lineage visibility* — which memories a branch can
# see. They used to store the same vector in every memory, which was
# harmless until M6 added redundancy suppression: four identical vectors are
# four copies of one statement as far as retrieval is concerned, so they
# collapsed to one and the lineage assertions could no longer be read.
#
# Each vector below sits at the same angle from the query `(1, 0, 0)`, so
# ranking between them is unchanged, and far enough apart from each other
# (pairwise cosine -0.28 to 0.36) that none suppresses another.
memories = { memories = {
"shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"]), "shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"],
"sibling": add_memory(db, adventure, "on A's own continuation", nodes["A5"]), vector=(0.6, 0.8, 0.0)),
"b": add_memory(db, adventure, "on B", nodes["B5"]), "sibling": add_memory(db, adventure, "on A's own continuation", nodes["A5"],
"c": add_memory(db, adventure, "on C", nodes["C7"]), vector=(0.6, -0.8, 0.0)),
"b": add_memory(db, adventure, "on B", nodes["B5"],
vector=(0.6, 0.0, 0.8)),
"c": add_memory(db, adventure, "on C", nodes["C7"],
vector=(0.6, 0.0, -0.8)),
} }
adventure.head_branch_id = c.id adventure.head_branch_id = c.id
adventure.head_depth = 7 adventure.head_depth = 7
@@ -162,6 +178,21 @@ def switch_to(db, adventure, branch_id, tip):
db.commit() db.commit()
@pytest.fixture(autouse=True)
def restore_embedding_provider():
"""Puts `memorybank.embedding_provider` back after every test here.
`retrieved` below replaces it by assignment. Until M6 nothing restored it,
so a stub outlived the module and was still installed when a later file ran
(`tests/test_provider_wiring.py`, which asserts on the real factory).
"""
real = memorybank.embedding_provider
try:
yield
finally:
memorybank.embedding_provider = real
def retrieved(adventure, settings) -> set[str]: def retrieved(adventure, settings) -> set[str]:
memorybank.embedding_provider = lambda s: StubEmbedder() memorybank.embedding_provider = lambda s: StubEmbedder()
result = asyncio.run( result = asyncio.run(
@@ -381,6 +412,8 @@ def deeply_forked():
memory_top_k=50, memory_top_k=50,
)) ))
_spread = 2 * math.pi / 14
def story(title, forks): def story(title, forks):
adventure = models.Adventure( adventure = models.Adventure(
user_id=user.id, title=title, script_state={}, memory_bank_enabled=True, user_id=user.id, title=title, script_state={}, memory_bank_enabled=True,
@@ -404,8 +437,20 @@ def deeply_forked():
nodes.append(add_node(db, adventure, branch, depth, "n")) nodes.append(add_node(db, adventure, branch, depth, "n"))
depth += 1 depth += 1
db.flush() db.flush()
_placed: list = []
for node in nodes[5::6]: # one memory per six actions, as the pass makes them for node in nodes[5::6]: # one memory per six actions, as the pass makes them
add_memory(db, adventure, f"memory at {node.depth}", node) # A distinct direction per memory, all at the same angle from the
# query, so ranking between them is unaffected and M6's redundancy
# suppression does not collapse fourteen distinct memories into one.
# This test measures bytes fetched, not deduplication.
#
# Fourteen directions spread evenly around the circle orthogonal to
# the query are 2*pi/14 apart; the small shared component keeps the
# closest pair at cosine ~0.905, below the 0.93 redundancy bar.
angle = _spread * len(_placed)
_placed.append(node)
add_memory(db, adventure, f"memory at {node.depth}", node,
vector=(0.2, 0.98 * math.cos(angle), 0.98 * math.sin(angle)))
adventure.head_branch_id = branch.id adventure.head_branch_id = branch.id
adventure.head_depth = depth - 1 adventure.head_depth = depth - 1
return adventure return adventure
+17
View File
@@ -36,6 +36,23 @@ class StubEmbedder:
return [list(self.vector) for _ in texts] return [list(self.vector) for _ in texts]
@pytest.fixture(autouse=True)
def restore_embedding_provider():
"""Puts `memorybank.embedding_provider` back after every test here.
The helpers below replace it by assignment rather than through monkeypatch,
and until M6 nothing put it back — so whichever test ran last left a stub
installed for the rest of the session. That was invisible while every other
memory test stubbed it too, and surfaced the moment M6 added a test that
asserts on the real factory (`tests/test_provider_wiring.py`).
"""
real = memorybank.embedding_provider
try:
yield
finally:
memorybank.embedding_provider = real
@pytest.fixture() @pytest.fixture()
def db(): def db():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
+7 -3
View File
@@ -29,7 +29,7 @@ import os
import pytest import pytest
from app import models, worldstate from app import models, summaries, worldstate
from app import narrative from app import narrative
from app.context import builder from app.context import builder
from app.database import Base, SessionLocal, engine from app.database import Base, SessionLocal, engine
@@ -97,7 +97,6 @@ def story():
user_id=user.id, title="A", scenario_id=scenario.id, script_state={}, user_id=user.id, title="A", scenario_id=scenario.id, script_state={},
memory="The hero hunts bandits.", memory="The hero hunts bandits.",
ai_instructions="Write in second person.", ai_instructions="Write in second person.",
story_summary="The hero left the village.",
world_state=worldstate.instantiate(SCHEMA), world_state=worldstate.instantiate(SCHEMA),
narrative_state=NARRATIVE, narrative_state=NARRATIVE,
# Phase 18. Set here so that every test in this file runs with a # Phase 18. Set here so that every test in this file runs with a
@@ -113,6 +112,11 @@ def story():
db.add(models.Action(adventure_id=adventure.id, db.add(models.Action(adventure_id=adventure.id,
type="ai" if i % 2 else "do", type="ai" if i % 2 else "do",
text=f"[{i}] The road bends onward past the treeline.")) text=f"[{i}] The road bends onward past the treeline."))
db.flush()
# M6: the summary is a row anchored to the story it covers, not a column.
# `build_context` reads whichever summary is eligible for the current head,
# so a test that wants one in the prompt has to record one.
summaries.record(db, adventure, "The hero left the village.")
db.commit() db.commit()
db.expire_all() db.expire_all()
adventure = db.get(models.Adventure, adventure.id) adventure = db.get(models.Adventure, adventure.id)
@@ -195,7 +199,7 @@ def test_live_sections_are_still_charged_to_the_budget(story):
settings = db.get(models.Settings, settings.id) settings = db.get(models.Settings, settings.id)
_, _, lean = builder.build_context(adventure, settings) _, _, lean = builder.build_context(adventure, settings)
adventure.story_summary = "The hero left the village. " * 150 summaries.record(db, adventure, "The hero left the village. " * 150)
db.commit() db.commit()
_, _, fat = builder.build_context(adventure, settings) _, _, fat = builder.build_context(adventure, settings)
+225
View File
@@ -0,0 +1,225 @@
"""M6: the provider construction path, exercised for real.
M2 shipped with the entire memory bank dead and the full suite green. The
summariser and the embedder were built from `Settings` attributes that had moved,
the resulting `AttributeError` was raised inside a fire-and-forget task, and
every memory test had stubbed the factories out — so nothing anywhere noticed
(`BUILD-MILESTONES.md`, note from M2).
These tests exist so that cannot happen twice. **Nothing here mocks a provider
factory.** They call the real factories with a real `Settings` row read back out
of the database, and assert that the configured values arrive at the object that
consumes them. A renamed or removed column fails here loudly instead of killing
the memory bank quietly.
Network is never touched: constructing a provider makes no request. The one test
that would make one is skipped unless a trusted-LAN endpoint is configured, and
it is reported separately from these.
python -m pytest tests/test_provider_wiring.py -v
"""
import os
import pytest
from app import endpoints, memorybank, models
from app.database import Base, SessionLocal, engine
from app.providers import OpenAICompatibleProvider, ProviderError
@pytest.fixture()
def settings():
"""A real Settings row, round-tripped through the database.
Round-tripping matters: a column that was renamed in the model but still
referenced by a factory fails on the read, which is the failure this module
is here to produce.
"""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
user = models.User(is_guest=False, email="wiring@example.com")
db.add(user)
db.flush()
row = models.Settings(
user_id=user.id,
api_key="enc:dummy",
endpoint_url="http://127.0.0.1:11434/v1",
model="narrator-model",
summary_model="summariser-model",
embedding_model="embedding-model",
api_mode="chat",
model_timeout_seconds=123,
max_output_tokens=456,
context_token_budget=4096,
)
db.add(row)
db.commit()
row_id = row.id
db.close()
db = SessionLocal()
try:
yield db.get(models.Settings, row_id)
finally:
db.close()
Base.metadata.drop_all(bind=engine)
# ------------------------------------------------- the real construction path
def test_the_summary_provider_is_built_from_the_configured_values(settings):
"""Every value the summariser needs reaches the provider that uses it."""
provider = memorybank.summary_provider(settings)
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.base_url == "http://127.0.0.1:11434/v1"
assert provider.model == "summariser-model"
assert provider.api_mode == "chat"
assert provider.read_timeout == 123
def test_the_summary_provider_falls_back_to_the_narrator_model(settings):
"""An empty summary model means "use the main one", not "use nothing"."""
settings.summary_model = ""
assert memorybank.summary_provider(settings).model == "narrator-model"
def test_the_embedding_provider_is_built_from_the_configured_values(settings):
provider = memorybank.embedding_provider(settings)
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.base_url == "http://127.0.0.1:11434/v1"
assert provider.model == "embedding-model"
def test_the_narrator_provider_is_built_from_the_configured_values(settings):
"""The turn path builds its own provider; the same values have to reach it."""
provider = OpenAICompatibleProvider(
settings.endpoint_url, settings.model, settings.api_mode,
settings.model_timeout_seconds,
)
assert provider.base_url == "http://127.0.0.1:11434/v1"
assert provider.model == "narrator-model"
assert provider.api_mode == "chat"
assert provider.read_timeout == 123
@pytest.mark.parametrize("attribute", [
"endpoint_url", "model", "summary_model", "embedding_model", "api_mode",
"model_timeout_seconds", "max_output_tokens", "context_token_budget",
])
def test_every_settings_attribute_the_providers_read_still_exists(settings, attribute):
"""The named guard against M2's failure.
Each attribute here is one a factory or the context builder reads. If a
migration renames one, this fails by name instead of the memory bank dying
in a task nobody is watching.
"""
assert hasattr(settings, attribute), (
f"Settings.{attribute} is gone; something that builds a provider reads it"
)
def test_building_a_provider_makes_no_request(settings):
"""Construction is inert, so these tests are safe to run offline."""
import socket
def refuse(*args, **kwargs): # pragma: no cover - only runs on a failure
raise AssertionError("provider construction opened a socket")
real = socket.socket.connect
socket.socket.connect = refuse
try:
memorybank.summary_provider(settings)
memorybank.embedding_provider(settings)
finally:
socket.socket.connect = real
# ------------------------------------------------------- the endpoint policy
def test_the_embedding_path_enforces_the_same_endpoint_policy(settings):
"""M6 section 18. Embedding inputs are story text, and they go to the same
kind of endpoint under the same rule as a narrator prompt.
Asserted through the real `embed()` rather than by reading the source: a
check that exists but is not reached would pass a source inspection.
"""
import asyncio
provider = OpenAICompatibleProvider("https://api.openai.com/v1", "embedding-model")
with pytest.raises(ProviderError) as exc:
asyncio.run(provider.embed(["a line of someone's story"]))
assert "can't be used" in str(exc.value)
def test_the_policy_refuses_a_public_address_for_embeddings():
"""The rule is the address, not the name of the caller."""
assert endpoints.rejection_reason("https://api.openai.com/v1/embeddings")
assert endpoints.rejection_reason("http://8.8.8.8:11434/v1/embeddings")
# And permits the local endpoints the product is built for.
assert endpoints.rejection_reason("http://127.0.0.1:11434/v1/embeddings") is None
def test_no_cloud_or_remote_vector_service_is_configured_anywhere():
"""M6 section 18: no new network path. Checked against the source, because
the point is that no such code exists to be exercised."""
import pathlib
forbidden = (
"api.openai.com", "api.anthropic.com", "pinecone", "weaviate",
"qdrant", "chromadb", "cohere.ai", "huggingface.co/api",
)
root = pathlib.Path(__file__).resolve().parent.parent / "app"
offenders = []
for path in root.rglob("*.py"):
text = path.read_text()
for needle in forbidden:
# `endpoints.py` names cloud hosts in order to refuse them.
if needle in text and path.name != "endpoints.py":
offenders.append(f"{path.name}: {needle}")
assert not offenders, offenders
# ------------------------------------------------- the live endpoint, if any
@pytest.mark.skipif(
not os.environ.get("AIDND_TEST_ENDPOINT"),
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
)
def test_the_real_construction_path_reaches_a_real_endpoint():
"""The wiring test with the network attached.
Reported separately from the tests above: this one proves the constructed
provider can actually talk to the configured endpoint, which is the half a
unit test cannot show. It uses the ordinary endpoint policy — no TLS
weakening, no allowlist bypass.
"""
import asyncio
endpoint = os.environ["AIDND_TEST_ENDPOINT"]
model = os.environ.get("AIDND_TEST_EMBED_MODEL", "nomic-embed-text")
assert endpoints.rejection_reason(endpoint) is None, (
"the configured test endpoint is refused by the policy"
)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
user = models.User(is_guest=False, email="live@example.com")
db.add(user)
db.flush()
row = models.Settings(
user_id=user.id, api_key="enc:dummy", endpoint_url=endpoint,
model=os.environ.get("AIDND_TEST_MODEL", ""), embedding_model=model,
)
db.add(row)
db.commit()
provider = memorybank.embedding_provider(row)
vectors = asyncio.run(provider.embed(["Aldric hid the ledger."]))
finally:
db.close()
Base.metadata.drop_all(bind=engine)
assert len(vectors) == 1
assert len(vectors[0]) > 8, "the endpoint returned no usable vector"
+2
View File
@@ -126,6 +126,7 @@ def pre_tree():
# another table still points at. Any future table that references these # another table still points at. Any future table that references these
# has to be added to the front of this list. # has to be added to the front of this list.
for table in ("state_events", "state_proposals", "checkpoints", for table in ("state_events", "state_proposals", "checkpoints",
"summaries", "derived_status",
"actions", "memories", "branches", "adventures"): "actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}")) conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL: for ddl in PRE_TREE_DDL:
@@ -600,6 +601,7 @@ def pre_split():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with engine.begin() as conn: with engine.begin() as conn:
for table in ("state_events", "state_proposals", "checkpoints", for table in ("state_events", "state_proposals", "checkpoints",
"summaries", "derived_status",
"actions", "memories", "branches", "adventures"): "actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}")) conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL: for ddl in PRE_TREE_DDL:
@@ -46,6 +46,12 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
)} )}
<div className={`token-total ${overBudget ? 'over' : ''}`}> <div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens {tokens.total} / {tokens.budget} tokens
{/* M6: the reply has to fit in the same window, so say how much of it
is being held back for one. Without this the reader can see that
the prompt fits and still get a truncated turn. */}
{tokens.output_reserve > 0 && (
<span className="dim"> · {tokens.output_reserve} reserved for the reply</span>
)}
</div> </div>
<TokenBreakdown <TokenBreakdown
sections={sections} sections={sections}
@@ -76,15 +82,65 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
)} )}
{report.memories.used?.map((m, i) => ( {report.memories.used?.map((m, i) => (
<div key={i}> <div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}): ▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`})
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text} {/* M6: what weight it carries, and where it came from. An
inference must not read like a record. */}
{m.authority === 'heuristic'
? <span className="mem-heuristic"> · inferred</span>
: <span className="dim"> · from the story</span>}
{m.source?.depth != null && (
<span className="dim">
{' '}· turn {m.source.source_start === m.source.source_end
? m.source.depth
: `${m.source.source_start}–${m.source.source_end}`}
</span>
)}
: {m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div> </div>
))} ))}
{report.memories.considered != null && (
<div className="dim">
{report.memories.considered} memories eligible on this line of the story.
</div>
)}
{!report.memories.error && report.memories.used?.length === 0 && ( {!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div> <div className="dim">Memory bank on — no memories retrieved.</div>
)} )}
</div> </div>
)} )}
{/* M6: which summary was used, and what stretch of story it covers, so
"what history did that summary cover?" is answerable here. */}
{report.summary ? (
<div className="insights-cards">
<div>
▸ summary in use
<span className="dim">
{' '}· covers {report.summary.source_start != null
? `turns ${report.summary.source_start}–${report.summary.source_end}`
: `up to turn ${report.summary.source_end ?? report.summary.depth}`}
{report.summary.trigger === 'manual' ? ' · written by you' : ' · generated'}
</span>
</div>
</div>
) : (
<div className="insights-cards dim">▸ no summary is eligible for this point in the story.</div>
)}
{/* M6: a dead memory bank used to be invisible. It is not any more. */}
{report.derived?.some((d) => d.status === 'failed') && (
<div className="insights-cards">
{report.derived.filter((d) => d.status === 'failed').map((d) => (
<div key={d.kind} className="dropped">
⚠ Background {d.kind} work is failing ({d.failures}
{d.failures === 1 ? ' attempt' : ' attempts'}): {d.detail}
</div>
))}
<div className="dim">
The story itself is unaffected — this only stops new {' '}
{report.derived.filter((d) => d.status === 'failed')
.map((d) => d.kind).join(' and ')} work being written.
</div>
</div>
)}
</div> </div>
{sections.map((s, i) => ( {sections.map((s, i) => (
+7
View File
@@ -500,3 +500,10 @@
color: var(--text-dim); color: var(--text-dim);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* M6: a retrieved memory that is an interpretation rather than a record. The
colour is the same one the state panel uses for a reader's own correction,
because both mean "weigh this differently from accepted story". */
.mem-heuristic {
color: #d8a657;
}
+80 -1
View File
@@ -1,6 +1,6 @@
# Adventure Storyteller — Production Build Milestones # Adventure Storyteller — Production Build Milestones
**Status:** In implementation. M1-M5 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04, after an independent review and a corrective pass); M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory — next to brief **Status:** In implementation. M1-M6 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04; M6: 2026-09-06, each of the last two after an independent review and a corrective pass); M7 — First-Class Imported Knowledge Library — next to brief
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de` **Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
## 1. Purpose ## 1. Purpose
@@ -699,6 +699,85 @@ Evidence: `planning/archive/milestone-reports/M2-IMPLEMENTATION-REPORT.md` §A.1
--- ---
## M6 — Outcome
**Complete and accepted 2026-09-06**, after an independent review that found E03
still failing and a corrective pass that fixed it. Report, including the review
findings and the corrective addendum:
`planning/reports/M6-IMPLEMENTATION-REPORT.md`.
**M7 is authorized**: the corrected M6 evidence passes, including E03 end to end
against a real local summariser.
What was inherited and kept, rather than rebuilt:
- **Memory lineage was already correct.** Memories carry `(branch_id, depth)`
and retrieval filters them through the capped-path clause. The ten-step
negative control was measured passing against the M5 baseline *before* any M6
change, and is now pinned by tests. M6 added provenance to the retrieval
result and an authority classification; it did not rewrite the bank.
- The lineage chokepoint, the cursors, and the post-turn pass are unchanged in
shape.
What M6 changed:
- **Summary lineage.** Summaries move from a single `adventures.story_summary`
column to `summaries` rows carrying a coordinate and a source range, filtered
by the same lineage clause as memories. E03 was measured leaking at the M5
baseline and is now closed.
- **Memory authority.** `accepted_story` vs `heuristic`, classified by the
application, marked in the prompt.
- **Context budgeting.** The reply is reserved out of the context budget, and an
impossible configuration raises `ContextOverflow` rather than producing a
prompt known to overflow. Nothing was reserved before M6.
- **Background failure observability.** `derived_status` rows, an API endpoint
and an Insights surface, so the M2 failure — the whole memory bank dead with a
green suite — is visible if it recurs.
- **Real provider-wiring tests**, which mock no factory, plus two pre-existing
test-suite leaks they exposed and which are fixed.
What the independent review found, and what the corrective pass did:
- **E03 was still failing (M6-F1).** Summary *rows* were lineage-anchored, but
generation was seeded from `adventures.story_summary`, a campaign-global
column with no lineage — so a summary generated after a divergence inherited
the abandoned line's prose inside a correctly anchored row. Fixed by seeding
from `summaries.current`. The lesson is recorded in `V1-ACCEPTANCE-TESTS.md`:
a valid E03 test must **regenerate** a summary after diverging, not merely
check that the old row went ineligible.
- **F02 passed by one slot (M6-F2).** With a real embedding model, four
near-identical memories crowded out the one that mattered; the clue survived
only because the default `memory_top_k` is 5. Redundancy suppression now runs
before the final cut, and the clue is retrieved at top_k 5, 4 and 3.
- **Two test defects (M6-F3, M6-F4).** The unit fixture made real network calls
to the default endpoint, and the E03 test and browser check shared the blind
spot above. Both fixed; the browser suite now has a dedicated E03 scenario
that regenerates a summary after divergence.
- **A misleading status (M6-F5).** Derived work that had nothing to do reported
`ok`; it now reports `idle`.
Debt carried forward, deliberately:
- **Retrieval ranking** is cosine similarity plus an explicit pin. Importance,
entity overlap, recency and thread overlap are contemplated by
`CONTEXT-AND-MEMORY.md` §20 and are not implemented. Redundancy suppression
covers the failure mode M6 measured; the richer ranking is open, and matters
to M7 because imported material will compete for the same budget.
- **Cross-layer duplication** (§22) is unimplemented: the same fact can appear
in state, memory and history at once. Bounded and legible, but not the
"highest-authority concise representation" the plan asks for.
- **M7:** imported knowledge is not implemented, and nothing was built to fill
its inspector section.
- **M8:** the Insights additions are functional, not designed; the broader UX
pass remains M8's.
- **M9:** derived rows are not carried in an export bundle, so an imported
campaign starts with no summaries or memories and rebuilds them. Authoritative
history is unaffected.
- **M11:** the long-context evidence here is a bounded fixture, not the M01
100-turn campaign.
---
# M7 — First-Class Imported Knowledge Library # M7 — First-Class Imported Knowledge Library
## Objective ## Objective
+106
View File
@@ -290,6 +290,42 @@ If the user restores or diverges before part of that source history:
A summary from abandoned history must never leak into active context. A summary from abandoned history must never leak into active context.
### As implemented (M6)
Lineage safety here has **two** halves, and the M6 review found that having only
the first is not enough.
**The row must be eligible.** A summary is a row with a coordinate, exactly as a
memory is: `branch_id` and `depth` name the last node it covers,
`source_start`/`source_end` the stretch. Eligibility is one question — is that
coordinate on the active, head-capped lineage? — answered by
`lineage.Path.clause`, the same chokepoint every read of the story goes through.
Undo, Redo, Save Point restore and divergence all fall out of that without a
rule of their own.
**The input must be eligible too.** Summary generation is seeded only from a
summary that is itself valid on the current head-capped lineage
(`summaries.current`). Where none is, the new line starts from no previous
summary.
The second half was missing in the first M6 implementation and E03 failed
because of it: the summariser seeded itself from `adventures.story_summary`, a
campaign-global column with no lineage, so after a divergence it was handed the
abandoned line's prose and asked to update it. The row it produced was correctly
anchored to the new branch and therefore *looked* lineage-safe while its
sentences described a story the reader had left. Anchoring the output is not
enough; the input has to be scoped by the same rule.
Abandoned summaries are retained, never deleted, and become eligible again if
the reader returns to the line that produced them.
`adventures.story_summary` remains, as a reader-facing convenience only: the
Plot panel edits it and the export bundle carries it. It is a mirror of whichever
summary is currently eligible — kept in step when one is written and when the
head moves — and **nothing authoritative may read it**. A summary the reader
types is recorded as a row anchored at the position they typed it at, so it
behaves like any other.
## 12. Story Memory ## 12. Story Memory
Long-term story memory should retrieve important older information that is not present in recent history or current summary. Long-term story memory should retrieve important older information that is not present in recent history or current summary.
@@ -372,6 +408,19 @@ The narrator should be told which memories are:
This avoids turning guesses into canon. This avoids turning guesses into canon.
### As implemented (M6)
Two values, `accepted_story` and `heuristic`, on `Memory.authority`. The
**application** classifies, not the model: `memorybank.classify_authority`
reads the memory's own text for hedging ("seemed", "appeared to", "probably"),
so an extractor cannot promote a guess by asserting it confidently. The prompt
marks a heuristic memory `[inferred]` and says in words that such lines are
interpretation rather than established fact.
Retrieval never writes state. A memory of either authority is something the
narrator is shown; the only path to an authoritative change remains the M5 typed
event pipeline (ADR 013).
## 15. Memory Creation ## 15. Memory Creation
Memories may be generated after accepted turns. Memories may be generated after accepted turns.
@@ -494,6 +543,16 @@ Potential ranking factors:
The final ranking formula should be simple and inspectable. The final ranking formula should be simple and inspectable.
### As implemented (M6)
Ranking is **cosine similarity against the retrieval query, plus an explicit
pin**. The other factors listed above — importance, lexical match, recency,
entity overlap, story-thread overlap — are **not implemented**, and remain
future work rather than something M6 delivered.
What M6 does implement, because similarity alone proved insufficient, is
redundancy suppression before the final selection: see §22.
## 21. Memory Budget ## 21. Memory Budget
Retrieved memories should have a bounded token budget. Retrieved memories should have a bounded token budget.
@@ -524,6 +583,31 @@ there is little value in also including three memories that say the same thing.
Context builder should prefer the highest-authority concise representation. Context builder should prefer the highest-authority concise representation.
### As implemented (M6)
**Between memories, yes.** Retrieval walks the ranked candidates and skips one
that repeats a memory already chosen, keeping the highest-ranked statement of a
fact and its provenance. Two rules bound it: authority is never crossed, so an
inference can never suppress a record or the reverse; and the bar is high, set
where measurement showed distinct facts stop appearing. The number of
suppressed candidates is reported in the context record, so a memory that was
considered and set aside can be told from one that was never eligible.
The threshold is a measured property of the embedding model in use, not a
universal constant, and `memorybank.REDUNDANT_SIMILARITY` records the
measurement beside the value.
The same measurement ruled out the more obvious test. Word overlap fires hardest
on exactly the pair that must not be merged — "Mara promised to return before
dawn" against "Aldric promised to return before dawn" shares most of its words
and means something else — and is weakest on filler that plainly repeats itself.
Wording is a poor proxy for sameness of fact.
**Across layers, not yet.** A fact can still appear in the authoritative state,
in a memory and in recent history at once. That is bounded and legible, but it
is not the "highest-authority concise representation" this section asks for, and
it remains open.
## 23. Imported Knowledge Categories ## 23. Imported Knowledge Categories
Imported local files must be classified as: Imported local files must be classified as:
@@ -672,6 +756,28 @@ Recommended behavior:
- add safety margin, - add safety margin,
- fail gracefully if protected context alone is too large. - fail gracefully if protected context alone is too large.
### As implemented (M6)
`context_token_budget` is the whole window, so the reply is subtracted from it
before any history is selected:
```text
available for history = context_token_budget
- (system, canon, state, summary, memories, user input)
- (max_output_tokens + 64)
```
The 64-token margin covers the separators added after budgeting and the drift
between the app's tokenizer and the serving model's; it is fixed rather than
proportional because what it absorbs does not scale with the budget.
When the protected part alone does not fit, `build_context` raises
`ContextOverflow` naming both figures and what to change, and the turn reports
that as a failed turn. It does not build a prompt it knows will overflow.
Before M6 nothing was reserved: the builder spent the entire budget on input and
left the reply to fit in whatever the endpoint had left.
## 33. Context Snapshot ## 33. Context Snapshot
Every narrator generation should preserve enough information to reconstruct the effective prompt. Every narrator generation should preserve enough information to reconstruct the effective prompt.
+34
View File
@@ -453,6 +453,40 @@ story_thread:
These are narrative continuity tools, not RPG quests. These are narrative continuity tools, not RPG quests.
## 16A. Derived Context Tables (M6, as implemented)
Two tables and one column carry M6's derived context. All three are derived
data: deleting them changes no accepted history, no authoritative state and no
head position.
```text
summaries
id, adventure_id
text
branch_id, depth the coordinate of the last node covered
source_start, source_end the stretch of story summarized
trigger "interval" (generated) or "manual" (reader-written)
model_name, created_at
derived_status one row per (adventure, kind)
kind "memory" | "summary" | "embedding"
status "ok" (did work) | "idle" (nothing pending) | "failed"
detail, failures
last_attempt_at, last_success_at
memories.authority "accepted_story" | "heuristic"
```
`summaries` mirrors the shape `memories` already had, deliberately: both are
derived rows anchored to a coordinate on a path, and both are filtered by the
same lineage clause — and both are also the *input* to the next round of derived
work, which is why summary generation reads `summaries.current` rather than any
campaign-global field.
`adventures.story_summary` is retained as the reader's edit surface and the
export field, mirroring whichever summary is eligible. It carries no lineage of
its own and nothing authoritative reads it.
## 17. State Version ## 17. State Version
The system must reconstruct authoritative state at any retained turn. The system must reconstruct authoritative state at any retained turn.
+11 -9
View File
@@ -3,9 +3,10 @@
**This file is the index. Start here.** **This file is the index. Start here.**
**Current state:** Phase 0 complete; AI-DnD forked as the production base; **Current state:** Phase 0 complete; AI-DnD forked as the production base;
milestones **M1, M2, M3 and M4 implemented and accepted** (M3 and M4: milestones **M1 through M6 implemented and accepted** (M3 and M4: 2026-09-03;
2026-09-03). M5: 2026-09-04; M6: 2026-09-06). M5 and M6 were each accepted only after an
**Next:** **M5 — Genre-Neutral Authoritative Narrative State.** Its brief has not independent review found a real defect and a corrective pass fixed it.
**Next:** **M7 — First-Class Imported Knowledge Library.** Its brief has not
been written yet, and writing it is the current action. been written yet, and writing it is the current action.
**Package version:** see `VERSION.md`, which records what each revision changed **Package version:** see `VERSION.md`, which records what each revision changed
@@ -245,13 +246,14 @@ M5-M11, one at a time see BUILD-MILESTONES.md
**One milestone at a time. Do not begin a milestone before its brief exists.** **One milestone at a time. Do not begin a milestone before its brief exists.**
**No M5 brief has been prepared.** Writing one is the current action, informed by **No M7 brief has been prepared.** Writing one is the current action, informed by
the M4 report's §U readiness assessment and by the note `BUILD-MILESTONES.md` the M6 report's §W readiness assessment and by the retrieval debt
attaches to M5 — in particular that M4 added 55 tests using the inherited `BUILD-MILESTONES.md` records against M6 — in particular that ranking is
world-state values as deterministic instrumentation, which M5 must **move rather similarity plus a pin, so imported material will compete for the same memory
than delete**. budget as story memory, and that cross-layer duplication
(`CONTEXT-AND-MEMORY.md` §22) is still open.
**No conditions remain open on M1-M4.** The browser smoke condition that M3 and **No conditions remain open on M1-M6.** The browser smoke condition that M3 and
M4 both carried was satisfied at M4 closeout: a real Firefox exercised the M4 both carried was satisfied at M4 closeout: a real Firefox exercised the
rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's
§M.2 and the M4 report's §M record the condition as it stood; the M4 report's §W §M.2 and the M4 report's §M record the condition as it stood; the M4 report's §W
+73
View File
@@ -394,6 +394,79 @@ any distance, in either direction, and identical whether the position is reached
from in front of it or from behind. This is the property §10.4's hybrid storage from in front of it or from behind. This is the property §10.4's hybrid storage
must preserve. must preserve.
### M6 — derived context: summaries, memory and budgeting
Four things future milestones rely on, all built on the lineage machinery M3-M5
established rather than beside it.
**Summary lineage — both halves.** A summary is a `summaries` row carrying
`(branch_id, depth)` for the last node it covers plus a
`source_start`/`source_end` range. The invariant M6 holds is:
> Both summary eligibility and the prior-summary input to the summarizer are
> lineage-scoped.
*Eligibility* is `lineage.Path.clause` over the row's coordinate — the same
capped-path clause that filters actions and memories — so Undo, Redo, Save Point
restore and divergence need no summary-specific rule. *Input* is
`summaries.current`, the same question the context builder asks, so a summary is
only ever built on top of one that is valid where the story now stands; where
none is, generation starts from nothing.
The second half is not decorative. The first M6 implementation had only the
first, seeding generation from `adventures.story_summary`, and the review
demonstrated abandoned prose reaching an active prompt inside a row that was
itself correctly anchored. Anchoring the output does not make the content safe.
Nothing is deleted when a line is abandoned. `adventures.story_summary` survives
as a reader-facing convenience only — the Plot panel edits it, the export bundle
carries it — mirroring whichever summary is eligible, kept in step by
`summaries.record` and by `attempts.restore_state` when the head moves. Nothing
authoritative reads it.
**Memory lineage and provenance.** Unchanged from what M3 built and M6 verified:
a memory carries `(branch_id, depth)` and a source range, and retrieval filters
through the capped path. M6 adds provenance to the *retrieval result*, in the
same query that fetches the text, so the inspector can answer "where did this
come from?" without a query per memory.
**Memory authority.** `Memory.authority` is `accepted_story` or `heuristic`,
decided by the application in `memorybank.classify_authority`, and rendered into
the prompt as an explicit mark. Retrieval never writes state; the M5 typed-event
path remains the only route to an authoritative change.
**Retrieval ranking and redundancy.** Ranking is cosine similarity plus an
explicit pin; the other factors `CONTEXT-AND-MEMORY.md` §20 contemplates are not
implemented. Before the final top-k cut, retrieval drops a candidate that
repeats one already chosen, never across authority classes, at a threshold
measured against the configured embedding model
(`memorybank.REDUNDANT_SIMILARITY`). Suppressed candidates are reported so the
selection stays inspectable. Without this, a stretch of repetitive story fills
the whole memory budget with near-copies and evicts the one memory that
mattered — which the review measured happening.
**Context budgeting.** The reply is reserved out of `context_token_budget`
before history is selected, with a fixed 64-token margin. Protected content —
narrator rules, canon, authoritative state, the reader's input, the reply
reserve — is never dropped to fit older prose; history is the elastic part and
is filled newest-first until the remaining budget is spent. If the protected
part alone exceeds the budget, `build_context` raises `ContextOverflow` rather
than assembling a prompt known to overflow.
**Background failure observability.** Derived work (memory extraction, summary
generation, embedding) runs in a fire-and-forget task and must not take an
accepted turn down with it. Each pass is wrapped so that a failure rolls back
only its own uncommitted work and writes a `derived_status` row naming the kind,
the error and the attempt count. That row is served by
`GET /adventures/{id}/derived` and shown in the Insights panel. M2 shipped with
the whole memory bank dead and the suite green; this is the mechanism that makes
the same failure visible.
**Prompt inspection.** The context report carries per-section token counts, the
budget, the output reserve, the protected total, the history allowance, the
summary's provenance, each retrieved memory's authority and source coordinate,
and the derived-work status.
**Divergence is a property of the lineage, not a flag.** The first write below a **Divergence is a property of the lineage, not a flag.** The first write below a
moved-back head forks; Undo alone never does. After the fork, the displaced moved-back head forks; Undo alone never does. After the fork, the displaced
future is no longer on the lineage being read, so ordinary Redo finds nothing future is no longer on the lineage being read, so ordinary Redo finds nothing
+123
View File
@@ -916,6 +916,15 @@ Continue enough turns to exercise long-term memory retrieval.
### Pass ### Pass
Narrator does not retrieve/use the discarded revelation as active-history truth. Narrator does not retrieve/use the discarded revelation as active-history truth.
### Result — PASS (M6, 2026-09-05)
The ten-step negative control is `test_e02_the_ten_step_memory_negative_control`,
with the Save Point variant beside it. Both assert on the assembled prompt and
on the eligibility clause, not on the narration.
Measured passing against the M5 baseline *before* any M6 change: memory lineage
was inherited correct, and M6's contribution here is the test that pins it.
--- ---
## E03 — Abandoned Summary Cannot Leak ## E03 — Abandoned Summary Cannot Leak
@@ -932,6 +941,42 @@ Narrator does not retrieve/use the discarded revelation as active-history truth.
### Pass ### Pass
Old summary content from abandoned future is not applied. Old summary content from abandoned future is not applied.
### Result — PASS (M6 corrective pass, 2026-09-06)
Failed twice before it passed, and the history matters because it defines the
shape a valid E03 test has to have.
1. At the M5 baseline the summary was a single column with no coordinate and
survived Undo plus divergence into the active prompt.
2. The first M6 implementation made summaries lineage-anchored rows, and the
test written for it checked that the **old row** became ineligible. The
independent review then found E03 still failing end to end: generation was
seeded from `adventures.story_summary`, so the summary produced *on the new
line* inherited the abandoned line's prose inside a correctly anchored row.
3. The corrective pass seeds generation from `summaries.current`.
**A valid E03 test must regenerate a summary after the divergence.** Checking
only that the old row is ineligible passes while the defect is live. The
regression now required is:
```text
path A: enough history for a real summary, sentinel established on it
POSITIVE CONTROL — the sentinel is in the path-A summary and prompt
move the head below the sentinel, diverge
path B: play far enough that a NEW summary is generated
prove a new summary row exists and is not path A's
prove no path-A action is on path B's lineage
prove the sentinel is absent from the new summary
prove the sentinel is absent from the complete active prompt
prove the old row is retained but ineligible
```
Evidence: `test_e03_a_summary_generated_after_divergence_carries_no_abandoned_content`
(deterministic, fails against the pre-corrective implementation); the same
sequence against a real local summariser; and a dedicated browser scenario that
regenerates a summary after diverging rather than repeating the old blind spot.
--- ---
## E04 — Scene State Is Lineage-Safe ## E04 — Scene State Is Lineage-Safe
@@ -960,6 +1005,12 @@ Conduct a multi-turn conversation with Mara.
### Pass ### Pass
Narrator remembers immediately preceding dialogue and actions. Narrator remembers immediately preceding dialogue and actions.
### Result — PASS (M6, 2026-09-05)
`test_f01_recent_turns_stay_in_the_prompt`: the preceding turns and the reader's
own input are present in the assembled prompt, asserted on the context report
rather than on the narration.
--- ---
## F02 — Old Important Event Retrieval ## F02 — Old Important Event Retrieval
@@ -974,6 +1025,27 @@ Narrator remembers immediately preceding dialogue and actions.
### Pass ### Pass
Relevant old clue can be recovered through summary/memory/state. Relevant old clue can be recovered through summary/memory/state.
### Result — PASS (M6 corrective pass, 2026-09-06)
A distinctive clue is planted, long turns are played over it, and the clue is
*absent* from the verbatim history sections and *present* through a retrieved
memory, with `history.included < history.total` — so recovery does not come from
sending the transcript.
The independent review found this passing only by a one-slot margin: with a real
embedding model, four near-identical filler memories scored 0.79–0.82 against
the clue's 0.61, so the clue placed fifth and survived only because the default
`memory_top_k` is 5. At 4 it was evicted and F02 failed.
The corrective pass added redundancy suppression before the final selection.
On the same fixture and the same real embedding model, 3 of 5 candidates are now
suppressed as repetitions and the clue is retrieved at `top_k` 5, 4 **and** 3.
Ranking itself remains cosine similarity plus an explicit pin; the further
factors `CONTEXT-AND-MEMORY.md` §20 contemplates are not implemented and are
recorded there as future work.
--- ---
## F03 — Prompt Remains Bounded ## F03 — Prompt Remains Bounded
@@ -986,6 +1058,13 @@ Generate a long story.
### Pass ### Pass
Application does not continually append full transcript until context overflows. Application does not continually append full transcript until context overflows.
### Result — PASS (M6, 2026-09-05)
`test_f03_the_prompt_stays_bounded_as_the_story_grows` and
`test_the_context_size_stops_growing_once_the_budget_is_reached`. The second
measures from a story that already fills the budget, then triples it: the
prompt does not move, while the action count does.
--- ---
## F04 — Output Token Reserve ## F04 — Output Token Reserve
@@ -995,6 +1074,14 @@ Application does not continually append full transcript until context overflows.
### Pass ### Pass
Context builder leaves sufficient room for narrator output and does not regularly fail because input consumes entire context. Context builder leaves sufficient room for narrator output and does not regularly fail because input consumes entire context.
### Result — PASS (M6, 2026-09-05)
The reply is reserved out of the context budget before history is selected, and
the report exposes it (`tokens.output_reserve`). Three tests: the reserve
survives a long story; an impossible budget raises `ContextOverflow` naming both
figures; and that refusal reaches the reader as a failed turn without disturbing
the stored story. Nothing was reserved before M6.
--- ---
## F05 — Prompt Inspector ## F05 — Prompt Inspector
@@ -1017,6 +1104,18 @@ User can determine at least:
Exact UI may vary. Exact UI may vary.
### Result — PARTIAL, complete for the components M6 owns (2026-09-05)
`test_f05_the_inspector_shows_every_component_m6_owns` asserts the report
carries narrator rules, authoritative state, the summary in use with its source
coverage, retrieved memories with authority and provenance, recent history, the
reader's input, model and settings, and per-component token costs alongside the
budget, the reply reserve and the history allowance. All of this is rendered in
the Insights panel and was verified in a real browser.
"Retrieved knowledge" is M7's imported-document section and is not implemented;
nothing was built to fill it.
--- ---
## F06 — Retrieval Provenance ## F06 — Retrieval Provenance
@@ -1026,6 +1125,13 @@ Exact UI may vary.
### Pass ### Pass
A retrieved memory or imported chunk can be traced to its source record/file. A retrieved memory or imported chunk can be traced to its source record/file.
### Result — PASS for story memory (M6, 2026-09-05)
`test_f06_a_retrieved_memory_is_traceable_to_its_source`: every retrieved memory
carries `branch_id`, `depth` and its source range, and the test resolves that
coordinate back to a real action of the campaign's accepted history. Imported
chunks are M7's half of this criterion and are not implemented.
--- ---
## F07 — Heuristic Memory Is Not Canon ## F07 — Heuristic Memory Is Not Canon
@@ -1048,6 +1154,15 @@ Mara is definitely working against Captain Vale.
as authoritative fact. as authoritative fact.
### Result — PASS (M6, 2026-09-05)
`Memory.authority` is `accepted_story` or `heuristic`, classified by the
application rather than the model. The prompt marks an inference `[inferred]`
and says such lines are not established fact.
`test_f07_a_heuristic_memory_is_labelled_and_is_not_state` also asserts the
inference did not become an authoritative fact: retrieval never writes state,
and the M5 typed-event path remains the only route to one.
--- ---
## F08 — Memory Failure Is Non-Fatal ## F08 — Memory Failure Is Non-Fatal
@@ -1060,6 +1175,14 @@ Cause embedding/memory extraction failure if test harness supports it.
### Pass ### Pass
Accepted turn persists and story can continue; derived memory may be retried later. Accepted turn persists and story can continue; derived memory may be retried later.
### Result — PASS (M6, 2026-09-05)
With the summariser and embedder both raising, the accepted narration, the
authoritative state, the head and the transcript all survive, the next turn
still plays, and the failure is recorded per kind in `derived_status` and served
by `GET /adventures/{id}/derived`. A later healthy run clears it. This is the
M2 failure — the whole memory bank dead with a green suite — made visible.
--- ---
# G. Imported Knowledge # G. Imported Knowledge
+46 -3
View File
@@ -1,8 +1,51 @@
# Planning Package Version # Planning Package Version
- **Package:** Adventure Storyteller Planning Package v2.6 - **Package:** Adventure Storyteller Planning Package v2.7
- **Revision date:** 2026-09-03 - **Revision date:** 2026-09-06
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M4 implemented and accepted**; M5 is next to brief. - **Status:** Phase 0 complete; architecture selected; **Milestones M1-M6 implemented and accepted**; M7 is next to brief.
## v2.7 — M5 and M6 Closeout (2026-09-06)
Two milestones, and one lesson they share: an independent review found a real
defect in each after the implementation reported success, and in both cases the
defect was invisible to the tests the implementation had written for itself.
**M5 — Genre-Neutral Authoritative Narrative State.** Accepted 2026-09-04. Its
review returned *PASS WITH CORRECTIVE WORK REQUIRED*: editing a narrator turn
rewound the campaign's live state while the head stayed at the tip, breaking the
`transcript position == head == authoritative state` invariant. The corrective
pass rebuilt narrator editing on the §§14-15 fork semantics. Recorded here late:
M5's closeout did not add a package revision entry, and this one covers it.
**M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory.** Accepted
2026-09-06. Sequence:
1. **Implementation.** Summaries moved onto lineage-anchored rows; memory
authority, provenance, an output reserve, and observable derived-work
failure were added. Reported as passing, including E03.
2. **Independent review — E03 still failed.** Summary *rows* were anchored, but
generation was seeded from `adventures.story_summary`, a campaign-global
column with no lineage. After a divergence the summariser was handed the
abandoned line's prose and asked to update it, so the new summary carried
abandoned content inside a correctly anchored row. The review also found F02
passing by a single retrieval slot, two test defects, and a misleading
derived-work status.
3. **Corrective pass.** Generation is seeded from `summaries.current`;
redundancy suppression was added before the retrieval cut; the test fixture
no longer makes real network calls; the browser suite gained a scenario that
regenerates a summary after divergence.
**What the planning package learned from M6, recorded in the active documents:**
- `CONTEXT-AND-MEMORY.md` §11 — lineage safety has two halves. Anchoring the
output row is not enough; the *input* to the summarizer must be scoped by the
same rule.
- `V1-ACCEPTANCE-TESTS.md` E03 — a valid test must regenerate a summary after
diverging. Checking only that the old row went ineligible passes while the
defect is live.
- `CONTEXT-AND-MEMORY.md` §20/§22 — what M6 implements (redundancy suppression
between memories) is now distinguished from what it does not (importance,
entity, recency and thread ranking; cross-layer duplication).
## v2.6 — M4 Closeout (2026-09-03) ## v2.6 — M4 Closeout (2026-09-03)
File diff suppressed because it is too large Load Diff