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