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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user