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:
co-authored by
Claude Opus 5
parent
b7005e6fdd
commit
a6e9c7a32b
+10
-2
@@ -34,9 +34,9 @@ agreed in every group.
|
||||
|
||||
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 .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)
|
||||
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
|
||||
# coherent with the position being read, so an old save is not left showing
|
||||
# a future's values. Nothing consults them to decide anything.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from . import history
|
||||
from .builder import (
|
||||
ContextOverflow,
|
||||
build_context,
|
||||
count_tokens,
|
||||
match_cards,
|
||||
@@ -9,7 +10,7 @@ from .builder import (
|
||||
from .history import story_actions
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
"ContextOverflow", "build_context",
|
||||
"count_tokens",
|
||||
"history",
|
||||
"match_cards",
|
||||
|
||||
@@ -21,8 +21,9 @@ block and the live sections in `build_context`.
|
||||
from dataclasses import dataclass
|
||||
|
||||
import tiktoken
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from .. import models, narrative, worldstate
|
||||
from .. import derived, models, narrative, summaries, worldstate
|
||||
from . import encoding, 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
|
||||
# `tiktoken.get_encoding("cl100k_base")` downloads it on first use, and this
|
||||
# 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:
|
||||
return encoding.get_encoding()
|
||||
|
||||
@@ -187,6 +206,12 @@ def _history_text(action: models.Action) -> str:
|
||||
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:
|
||||
"""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
|
||||
# turn. `world_lore` is added below, because the history window determines
|
||||
# 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 = (
|
||||
Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}")
|
||||
if adventure.story_summary.strip()
|
||||
Section("story_summary", f"Story summary:\n{summary_text}")
|
||||
if summary_text
|
||||
else None
|
||||
)
|
||||
memories_section = None
|
||||
if memory_bank and memory_bank.get("used"):
|
||||
lines_text = "\n".join(f"- {m['text']}" for m in memory_bank["used"])
|
||||
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
|
||||
# M6: an inference must not read as a record. A heuristic memory is
|
||||
# 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
|
||||
refusal_note = ""
|
||||
# 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(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
|
||||
# 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
|
||||
],
|
||||
"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": {
|
||||
"total": count_tokens(system_text) + count_tokens(story_text),
|
||||
"budget": settings.context_token_budget,
|
||||
"output_reserve": output_reserve,
|
||||
"protected": reserved,
|
||||
"available_for_history": available,
|
||||
"history_spent": spent,
|
||||
},
|
||||
"cards": card_records,
|
||||
"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": {
|
||||
"included": len(included_actions),
|
||||
# The count covers the whole story rather than the window fetched
|
||||
|
||||
@@ -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"]
|
||||
+246
-40
@@ -27,13 +27,14 @@ succeeds.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from array import array
|
||||
from collections import OrderedDict
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session, defer, object_session
|
||||
|
||||
from . import models, tree, vectors
|
||||
from . import derived, models, summaries, tree, vectors
|
||||
from .context import (
|
||||
cursors,
|
||||
history,
|
||||
@@ -46,6 +47,8 @@ from .database import SessionLocal
|
||||
from .providers import OpenAICompatibleProvider, ProviderError
|
||||
from .vectors import cosine # re-exported: the ranking lives here, the maths there
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MEMORY_INTERVAL = 6 # actions per memory
|
||||
MEMORY_START = 12 # first memory once the adventure reaches this many actions
|
||||
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
|
||||
# M2 removed cloud providers. `summary_model` and `embedding_model` fall back to
|
||||
# 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:
|
||||
return OpenAICompatibleProvider(
|
||||
settings.endpoint_url,
|
||||
@@ -473,7 +574,7 @@ async def retrieve_memories(
|
||||
# affordable because memories are sparse, at roughly one per six actions, so
|
||||
# even a heavily forked story returns only tens of small rows.
|
||||
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,
|
||||
lineage.path_of(db, adventure).clause(models.Memory),
|
||||
models.Memory.forgotten.is_(False),
|
||||
@@ -495,11 +596,12 @@ async def retrieve_memories(
|
||||
except ProviderError as 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(
|
||||
(
|
||||
(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
|
||||
),
|
||||
key=lambda row: row[0],
|
||||
@@ -511,19 +613,31 @@ async def retrieve_memories(
|
||||
top_k = max(1, settings.memory_top_k)
|
||||
used = [row for row in scored if row[2]]
|
||||
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)
|
||||
if not used:
|
||||
return {"used": [], "error": None}
|
||||
|
||||
# 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]
|
||||
texts = dict(
|
||||
db.execute(
|
||||
select(models.Memory.id, models.Memory.text)
|
||||
.where(models.Memory.id.in_(used_ids))
|
||||
detail = {
|
||||
row.id: row
|
||||
for row in db.execute(
|
||||
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()
|
||||
)
|
||||
}
|
||||
texts = {memory_id: row.text for memory_id, row in detail.items()}
|
||||
|
||||
if update_stats:
|
||||
# Pass `synchronize_session=False` because nothing in this request
|
||||
@@ -539,10 +653,29 @@ async def retrieve_memories(
|
||||
|
||||
return {
|
||||
"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
|
||||
],
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
# reports that there is nothing to do, and once the story grows past the
|
||||
# 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:
|
||||
await _create_due_memories(adventure, settings, db)
|
||||
await _update_story_summary(adventure, settings, db)
|
||||
await _guarded(db, adventure_id, derived.MEMORY,
|
||||
_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():
|
||||
await _embed_pending(adventure, settings, db)
|
||||
await _guarded(db, adventure_id, derived.EMBEDDING,
|
||||
_embed_pending(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:
|
||||
db.close()
|
||||
_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(
|
||||
adventure: models.Adventure,
|
||||
provider: OpenAICompatibleProvider,
|
||||
@@ -629,37 +800,39 @@ async def summarize_block(
|
||||
|
||||
async def _create_due_memories(
|
||||
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)
|
||||
written = 0
|
||||
for _ in range(MAX_MEMORIES_PER_RUN):
|
||||
# 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
|
||||
# both numbers must be current.
|
||||
anchor = cursors.MEMORY.depth(db, adventure)
|
||||
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
|
||||
# for story past its end. See `SETTLE_SLACK`.
|
||||
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
|
||||
# no memory is due, and the first check settles that without measuring
|
||||
# the length of the whole story.
|
||||
block = history.after(adventure, anchor, MEMORY_INTERVAL)
|
||||
if len(block) < MEMORY_INTERVAL:
|
||||
return
|
||||
try:
|
||||
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.
|
||||
return written
|
||||
# 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)
|
||||
if not text:
|
||||
return
|
||||
return written
|
||||
memory = models.Memory(
|
||||
adventure_id=adventure.id,
|
||||
text=text,
|
||||
source_start=block[0].depth,
|
||||
source_end=block[-1].depth,
|
||||
authority=classify_authority(text),
|
||||
)
|
||||
# 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
|
||||
@@ -670,22 +843,27 @@ async def _create_due_memories(
|
||||
db.add(memory)
|
||||
cursors.MEMORY.anchor_at(adventure, block[-1])
|
||||
db.commit()
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
async def _update_story_summary(
|
||||
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)
|
||||
uncovered = history.count_after(adventure, anchor)
|
||||
if uncovered < SUMMARY_INTERVAL:
|
||||
return
|
||||
return False
|
||||
# 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
|
||||
# pass saw it, and a turn that arrives during the call must not be counted
|
||||
# as read.
|
||||
caught_up = history.newest(adventure)
|
||||
if caught_up is None:
|
||||
return
|
||||
return False
|
||||
|
||||
# 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
|
||||
@@ -707,7 +885,25 @@ async def _update_story_summary(
|
||||
block = history.after(adventure, anchor, uncovered)
|
||||
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
|
||||
# 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
|
||||
@@ -720,22 +916,31 @@ async def _update_story_summary(
|
||||
)
|
||||
if brief:
|
||||
user_prompt = f"{brief}\n\n{user_prompt}"
|
||||
try:
|
||||
text = await summary_provider(settings).complete(
|
||||
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
|
||||
)
|
||||
except ProviderError:
|
||||
return
|
||||
text = await summary_provider(settings).complete(
|
||||
SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600
|
||||
)
|
||||
if not text:
|
||||
return
|
||||
adventure.story_summary = text
|
||||
return False
|
||||
# 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)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def _embed_pending(
|
||||
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
|
||||
# every turn and loaded the whole bank's vectors in order to find the few
|
||||
# rows with none.
|
||||
@@ -759,14 +964,15 @@ async def _embed_pending(
|
||||
.all()
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
return 0
|
||||
try:
|
||||
new = await embedding_provider(settings).embed([m.text for m in pending])
|
||||
except ProviderError:
|
||||
return
|
||||
return 0
|
||||
for memory, vector in zip(pending, new):
|
||||
set_vector(memory, vector)
|
||||
db.commit()
|
||||
return len(pending)
|
||||
|
||||
|
||||
def _evict_over_capacity(
|
||||
|
||||
@@ -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
|
||||
# snapshot. See `_backfill_narrative_snapshots`.
|
||||
(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)
|
||||
@@ -416,6 +425,7 @@ CURSOR_ANCHOR_VERSION = 56
|
||||
SIBLING_SPLIT_VERSION = 60
|
||||
PARENT_BACKFILL_VERSION = 64
|
||||
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
|
||||
# next node goes at `head_depth + 1` true without a special case. This matches
|
||||
@@ -433,6 +443,49 @@ SNAPSHOT_BATCH = 50
|
||||
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:
|
||||
"""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)
|
||||
if version == NARRATIVE_SNAPSHOT_VERSION:
|
||||
_backfill_narrative_snapshots(conn)
|
||||
if version == SUMMARY_LINEAGE_VERSION:
|
||||
_backfill_summary_lineage(conn)
|
||||
current = version
|
||||
_set_version(conn, current)
|
||||
|
||||
+104
-1
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, LargeBinary,
|
||||
String, Text, event,
|
||||
String, Text, UniqueConstraint, event,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
|
||||
|
||||
@@ -98,6 +98,19 @@ class Adventure(Base):
|
||||
memory: Mapped[str] = mapped_column(Text, default="")
|
||||
authors_note: 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="")
|
||||
# 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
|
||||
@@ -198,6 +211,17 @@ class Adventure(Base):
|
||||
cascade="all, delete-orphan",
|
||||
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):
|
||||
@@ -467,6 +491,14 @@ class Memory(Base):
|
||||
# current. Readers need only the yes-or-no answer, and fetching six
|
||||
# kilobytes of vector to get it is too expensive.
|
||||
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)
|
||||
forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -476,6 +508,77 @@ class Memory(Base):
|
||||
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):
|
||||
"""Owned by either a scenario or an adventure (exactly one set)."""
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
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
|
||||
|
||||
@@ -294,8 +295,18 @@ def update_adventure(
|
||||
db: Session = Depends(get_db),
|
||||
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)
|
||||
# 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()
|
||||
return adventure
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ returns the prompt a turn was actually generated from. Neither writes anything.
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import memorybank, models
|
||||
from ...context import build_context
|
||||
from ... import derived, memorybank, models, summaries
|
||||
from ...context import ContextOverflow, build_context
|
||||
from ...database import get_db
|
||||
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."""
|
||||
settings = get_settings(db, user)
|
||||
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False)
|
||||
_, _, report = build_context(adventure, settings, memories)
|
||||
try:
|
||||
_, _, 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
|
||||
|
||||
|
||||
@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")
|
||||
def action_context(
|
||||
adventure_id: int,
|
||||
|
||||
@@ -16,7 +16,7 @@ from ... import (
|
||||
attempts, head, limits, memorybank, models, narrative, schemas, tree,
|
||||
worldstate,
|
||||
)
|
||||
from ...context import build_context, cursors
|
||||
from ...context import ContextOverflow, build_context, cursors
|
||||
from ...database import get_db
|
||||
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
||||
from ...sse import SSE_HEADERS, sse, turn_error
|
||||
@@ -164,9 +164,18 @@ async def _generate_turn(
|
||||
memories = await memorybank.retrieve_memories(
|
||||
adventure, settings, update_stats=True, exclude_action_id=replacing_id
|
||||
)
|
||||
system_text, story_text, snapshot = build_context(
|
||||
adventure, settings, memories, exclude_action_id=replacing_id
|
||||
)
|
||||
try:
|
||||
system_text, story_text, snapshot = build_context(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -108,7 +108,12 @@ def actions_loaded():
|
||||
|
||||
# ------------------------------------------------------- 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):
|
||||
db, adventure, settings = story
|
||||
settings.context_token_budget = budget
|
||||
|
||||
@@ -216,8 +216,13 @@ def test_prompt_stays_inside_the_budget_on_a_long_story(story):
|
||||
db.expire_all()
|
||||
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.context_token_budget = 2048
|
||||
settings.context_token_budget = 8192
|
||||
|
||||
_, _, report = builder.build_context(adventure, settings)
|
||||
assert report["history"]["included"] < 120, "budget was never actually filled"
|
||||
|
||||
@@ -20,6 +20,7 @@ exactly as `test_branch_clause.py` builds it.
|
||||
python -m pytest tests/test_memory_nodes.py -v
|
||||
"""
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -138,11 +139,26 @@ def forked():
|
||||
nodes[f"C{depth}"] = add_node(db, adventure, c, depth, "C")
|
||||
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 = {
|
||||
"shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"]),
|
||||
"sibling": add_memory(db, adventure, "on A's own continuation", nodes["A5"]),
|
||||
"b": add_memory(db, adventure, "on B", nodes["B5"]),
|
||||
"c": add_memory(db, adventure, "on C", nodes["C7"]),
|
||||
"shared": add_memory(db, adventure, "on the shared trunk", nodes["A3"],
|
||||
vector=(0.6, 0.8, 0.0)),
|
||||
"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"],
|
||||
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_depth = 7
|
||||
@@ -162,6 +178,21 @@ def switch_to(db, adventure, branch_id, tip):
|
||||
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]:
|
||||
memorybank.embedding_provider = lambda s: StubEmbedder()
|
||||
result = asyncio.run(
|
||||
@@ -381,6 +412,8 @@ def deeply_forked():
|
||||
memory_top_k=50,
|
||||
))
|
||||
|
||||
_spread = 2 * math.pi / 14
|
||||
|
||||
def story(title, forks):
|
||||
adventure = models.Adventure(
|
||||
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"))
|
||||
depth += 1
|
||||
db.flush()
|
||||
_placed: list = []
|
||||
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_depth = depth - 1
|
||||
return adventure
|
||||
|
||||
@@ -36,6 +36,23 @@ class StubEmbedder:
|
||||
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()
|
||||
def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -29,7 +29,7 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app import models, worldstate
|
||||
from app import models, summaries, worldstate
|
||||
from app import narrative
|
||||
from app.context import builder
|
||||
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={},
|
||||
memory="The hero hunts bandits.",
|
||||
ai_instructions="Write in second person.",
|
||||
story_summary="The hero left the village.",
|
||||
world_state=worldstate.instantiate(SCHEMA),
|
||||
narrative_state=NARRATIVE,
|
||||
# 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,
|
||||
type="ai" if i % 2 else "do",
|
||||
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.expire_all()
|
||||
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)
|
||||
|
||||
_, _, 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()
|
||||
_, _, fat = builder.build_context(adventure, settings)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -126,6 +126,7 @@ def pre_tree():
|
||||
# another table still points at. Any future table that references these
|
||||
# has to be added to the front of this list.
|
||||
for table in ("state_events", "state_proposals", "checkpoints",
|
||||
"summaries", "derived_status",
|
||||
"actions", "memories", "branches", "adventures"):
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
for ddl in PRE_TREE_DDL:
|
||||
@@ -600,6 +601,7 @@ def pre_split():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
for table in ("state_events", "state_proposals", "checkpoints",
|
||||
"summaries", "derived_status",
|
||||
"actions", "memories", "branches", "adventures"):
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
for ddl in PRE_TREE_DDL:
|
||||
|
||||
Reference in New Issue
Block a user