mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
- Add hasContiguousJournalCoverageThroughFloor to check if current batchJournal contiguously covers back through the old retained floor - Add reconcileManualBackupBatchJournalCoverage that clears stale coverage when actual journals have bridged the gap - Self-clean during normalizeGraphRuntimeState (load-time) and appendBatchJournal (write-time) - findJournalRecoveryPoint now uses reconciled coverage so stale manual backup floors no longer block valid reverse-journal recovery - Add regression tests for gap-not-yet-bridged and bridged scenarios
367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY,
|
|
appendBatchJournal,
|
|
clearHistoryDirty,
|
|
cloneGraphSnapshot,
|
|
createBatchJournalEntry,
|
|
detectHistoryMutation,
|
|
findJournalRecoveryPoint,
|
|
normalizeGraphRuntimeState,
|
|
PROCESSED_MESSAGE_HASH_VERSION,
|
|
rebindProcessedHistoryStateToChat,
|
|
rollbackBatch,
|
|
snapshotProcessedMessageHashes,
|
|
} from "../runtime/runtime-state.js";
|
|
import { createEmptyGraph } from "../graph/graph.js";
|
|
import { normalizeKnowledgeState } from "../graph/knowledge-state.js";
|
|
|
|
const chat = [
|
|
{ is_user: true, mes: "你好" },
|
|
{ is_user: false, mes: "我记住了。" },
|
|
{ is_user: true, mes: "继续" },
|
|
{ is_user: false, mes: "新的回复" },
|
|
];
|
|
|
|
const hashes = snapshotProcessedMessageHashes(chat, 3);
|
|
const cleanDetection = detectHistoryMutation(chat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
});
|
|
assert.equal(cleanDetection.dirty, false);
|
|
|
|
const missingHashesDetection = detectHistoryMutation(chat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: {},
|
|
});
|
|
assert.equal(missingHashesDetection.dirty, true);
|
|
assert.equal(missingHashesDetection.earliestAffectedFloor, 0);
|
|
|
|
const sparseHashesDetection = detectHistoryMutation(chat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: {
|
|
0: hashes[0],
|
|
2: hashes[2],
|
|
3: hashes[3],
|
|
},
|
|
});
|
|
assert.equal(sparseHashesDetection.dirty, true);
|
|
assert.equal(sparseHashesDetection.earliestAffectedFloor, 1);
|
|
|
|
const editedChat = structuredClone(chat);
|
|
editedChat[1].mes = "我改过内容了。";
|
|
const editedDetection = detectHistoryMutation(editedChat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
});
|
|
assert.equal(editedDetection.dirty, true);
|
|
assert.equal(editedDetection.earliestAffectedFloor, 1);
|
|
|
|
const bmeHiddenChat = structuredClone(chat);
|
|
bmeHiddenChat[1].is_system = true;
|
|
bmeHiddenChat[1].extra = { __st_bme_hide_managed: true };
|
|
const bmeHiddenDetection = detectHistoryMutation(bmeHiddenChat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
});
|
|
assert.equal(bmeHiddenDetection.dirty, false);
|
|
|
|
const realSystemFlipChat = structuredClone(chat);
|
|
realSystemFlipChat[1].is_system = true;
|
|
const realSystemFlipDetection = detectHistoryMutation(realSystemFlipChat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
});
|
|
assert.equal(realSystemFlipDetection.dirty, false);
|
|
|
|
const migratedGraph = normalizeGraphRuntimeState({
|
|
historyState: {
|
|
chatId: "chat-history-test",
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: 1,
|
|
processedMessageHashes: hashes,
|
|
},
|
|
});
|
|
assert.equal(
|
|
migratedGraph.historyState.processedMessageHashVersion,
|
|
PROCESSED_MESSAGE_HASH_VERSION,
|
|
);
|
|
assert.deepEqual(migratedGraph.historyState.processedMessageHashes, {});
|
|
assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true);
|
|
|
|
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
|
|
assert.equal(migratedDetection.dirty, false);
|
|
|
|
const emptyHashGraph = normalizeGraphRuntimeState({
|
|
historyState: {
|
|
chatId: "chat-history-test",
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: {},
|
|
processedMessageHashesNeedRefresh: false,
|
|
},
|
|
});
|
|
assert.equal(emptyHashGraph.historyState.processedMessageHashesNeedRefresh, true);
|
|
|
|
const importedGraph = normalizeGraphRuntimeState({
|
|
historyState: {
|
|
chatId: "chat-history-test",
|
|
lastProcessedAssistantFloor: 99,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: {},
|
|
processedMessageHashesNeedRefresh: true,
|
|
},
|
|
});
|
|
const reboundResult = rebindProcessedHistoryStateToChat(importedGraph, chat, [
|
|
1,
|
|
3,
|
|
]);
|
|
assert.equal(reboundResult.rebound, true);
|
|
assert.equal(reboundResult.lastProcessedAssistantFloor, 3);
|
|
assert.equal(reboundResult.clamped, true);
|
|
assert.equal(importedGraph.historyState.processedMessageHashesNeedRefresh, false);
|
|
assert.deepEqual(
|
|
importedGraph.historyState.processedMessageHashes,
|
|
snapshotProcessedMessageHashes(chat, 3),
|
|
);
|
|
|
|
const danglingKnowledgeGraph = createEmptyGraph();
|
|
danglingKnowledgeGraph.nodes.push({
|
|
id: "live-node",
|
|
type: "event",
|
|
fields: { title: "仍存在", summary: "仍存在的节点" },
|
|
seq: 1,
|
|
seqRange: [1, 1],
|
|
archived: false,
|
|
embedding: null,
|
|
importance: 5,
|
|
accessCount: 0,
|
|
lastAccessTime: Date.now(),
|
|
createdTime: Date.now(),
|
|
level: 0,
|
|
parentId: null,
|
|
childIds: [],
|
|
prevId: null,
|
|
nextId: null,
|
|
clusters: [],
|
|
});
|
|
danglingKnowledgeGraph.knowledgeState.owners["character:艾琳"] = {
|
|
ownerType: "character",
|
|
ownerKey: "character:艾琳",
|
|
ownerName: "艾琳",
|
|
nodeId: "ghost-owner-node",
|
|
knownNodeIds: ["ghost-node", "live-node"],
|
|
mistakenNodeIds: ["ghost-mistaken"],
|
|
manualKnownNodeIds: ["ghost-manual-known"],
|
|
manualHiddenNodeIds: ["ghost-manual-hidden"],
|
|
visibilityScores: {
|
|
"ghost-node": 1,
|
|
"live-node": 0.9,
|
|
},
|
|
};
|
|
const normalizedKnowledgeState = normalizeKnowledgeState(
|
|
danglingKnowledgeGraph.knowledgeState,
|
|
danglingKnowledgeGraph,
|
|
);
|
|
assert.deepEqual(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.knownNodeIds,
|
|
["live-node"],
|
|
);
|
|
assert.deepEqual(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.mistakenNodeIds,
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.manualKnownNodeIds,
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.manualHiddenNodeIds,
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.visibilityScores,
|
|
{ "live-node": 0.9 },
|
|
);
|
|
assert.equal(
|
|
normalizedKnowledgeState.owners["character:艾琳"]?.nodeId || "",
|
|
"",
|
|
);
|
|
|
|
const clearedGraph = normalizeGraphRuntimeState({
|
|
historyState: {
|
|
chatId: "chat-history-test",
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
processedMessageHashesNeedRefresh: false,
|
|
},
|
|
});
|
|
clearHistoryDirty(clearedGraph, { status: "replayed" });
|
|
assert.deepEqual(clearedGraph.historyState.processedMessageHashes, {});
|
|
assert.equal(clearedGraph.historyState.processedMessageHashesNeedRefresh, true);
|
|
|
|
const truncatedChat = chat.slice(0, 2);
|
|
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
|
lastProcessedAssistantFloor: 3,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
processedMessageHashes: hashes,
|
|
});
|
|
assert.equal(truncatedDetection.dirty, true);
|
|
assert.equal(truncatedDetection.earliestAffectedFloor, 2);
|
|
|
|
const graph = createEmptyGraph();
|
|
graph.historyState.chatId = "chat-history-test";
|
|
const beforeSnapshot = cloneGraphSnapshot(graph);
|
|
graph.nodes.push({
|
|
id: "node-1",
|
|
type: "event",
|
|
fields: { title: "旧事件", summary: "旧摘要" },
|
|
seq: 1,
|
|
seqRange: [1, 1],
|
|
archived: false,
|
|
embedding: null,
|
|
importance: 5,
|
|
accessCount: 0,
|
|
lastAccessTime: Date.now(),
|
|
createdTime: Date.now(),
|
|
level: 0,
|
|
parentId: null,
|
|
childIds: [],
|
|
prevId: null,
|
|
nextId: null,
|
|
clusters: [],
|
|
});
|
|
graph.lastProcessedSeq = 3;
|
|
graph.historyState.lastProcessedAssistantFloor = 3;
|
|
graph.historyState.processedMessageHashes = hashes;
|
|
graph.historyState.extractionCount = 4;
|
|
graph.knowledgeState.owners["character:艾琳"] = {
|
|
ownerType: "character",
|
|
ownerKey: "character:艾琳",
|
|
ownerName: "艾琳",
|
|
knownNodeIds: ["node-1"],
|
|
visibilityScores: { "node-1": 1 },
|
|
};
|
|
const afterSnapshot = cloneGraphSnapshot(graph);
|
|
appendBatchJournal(
|
|
graph,
|
|
createBatchJournalEntry(beforeSnapshot, afterSnapshot, {
|
|
processedRange: [1, 3],
|
|
postProcessArtifacts: ["compression"],
|
|
vectorHashesInserted: [1234],
|
|
extractionCountBefore: 0,
|
|
}),
|
|
);
|
|
|
|
const recoveryPoint = findJournalRecoveryPoint(graph, 2);
|
|
assert.ok(recoveryPoint);
|
|
assert.equal(recoveryPoint.path, "reverse-journal");
|
|
assert.equal(recoveryPoint.affectedJournals[0].processedRange[1], 3);
|
|
|
|
const truncatedCoverageGraph = createEmptyGraph();
|
|
truncatedCoverageGraph.historyState.chatId = "chat-truncated-history-test";
|
|
truncatedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
|
|
truncated: true,
|
|
earliestRetainedFloor: 4,
|
|
retainedCount: 4,
|
|
};
|
|
truncatedCoverageGraph.batchJournal = [
|
|
{ id: "journal-4", journalVersion: 2, processedRange: [4, 4] },
|
|
{ id: "journal-5", journalVersion: 2, processedRange: [5, 5] },
|
|
{ id: "journal-6", journalVersion: 2, processedRange: [6, 6] },
|
|
{ id: "journal-7", journalVersion: 2, processedRange: [7, 7] },
|
|
];
|
|
assert.equal(
|
|
findJournalRecoveryPoint(truncatedCoverageGraph, 3),
|
|
null,
|
|
"dirty floor earlier than retained backup coverage should reject partial rollback",
|
|
);
|
|
const retainedCoverageRecoveryPoint = findJournalRecoveryPoint(
|
|
truncatedCoverageGraph,
|
|
5,
|
|
);
|
|
assert.ok(retainedCoverageRecoveryPoint);
|
|
assert.equal(retainedCoverageRecoveryPoint.path, "reverse-journal");
|
|
assert.equal(retainedCoverageRecoveryPoint.affectedJournals.length, 3);
|
|
|
|
const bridgedCoverageGraph = createEmptyGraph();
|
|
bridgedCoverageGraph.historyState.chatId = "chat-bridged-history-test";
|
|
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
|
|
truncated: true,
|
|
earliestRetainedFloor: 4,
|
|
retainedCount: 4,
|
|
};
|
|
bridgedCoverageGraph.batchJournal = [
|
|
{ id: "journal-4", journalVersion: 2, processedRange: [4, 4] },
|
|
{ id: "journal-5", journalVersion: 2, processedRange: [5, 5] },
|
|
];
|
|
appendBatchJournal(bridgedCoverageGraph, {
|
|
id: "journal-2",
|
|
journalVersion: 2,
|
|
processedRange: [2, 2],
|
|
});
|
|
assert.deepEqual(
|
|
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
|
|
{
|
|
truncated: true,
|
|
earliestRetainedFloor: 4,
|
|
retainedCount: 4,
|
|
},
|
|
);
|
|
appendBatchJournal(bridgedCoverageGraph, {
|
|
id: "journal-3",
|
|
journalVersion: 2,
|
|
processedRange: [3, 3],
|
|
});
|
|
assert.equal(
|
|
bridgedCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
|
|
null,
|
|
);
|
|
|
|
const recoveredCoverageGraph = createEmptyGraph();
|
|
recoveredCoverageGraph.historyState.chatId = "chat-recovered-history-test";
|
|
recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
|
|
truncated: true,
|
|
earliestRetainedFloor: 4,
|
|
retainedCount: 4,
|
|
};
|
|
recoveredCoverageGraph.batchJournal = [
|
|
{ id: "journal-2", journalVersion: 2, processedRange: [2, 2] },
|
|
{ id: "journal-3", journalVersion: 2, processedRange: [3, 3] },
|
|
{ id: "journal-4", journalVersion: 2, processedRange: [4, 4] },
|
|
{ id: "journal-5", journalVersion: 2, processedRange: [5, 5] },
|
|
];
|
|
normalizeGraphRuntimeState(
|
|
recoveredCoverageGraph,
|
|
recoveredCoverageGraph.historyState.chatId,
|
|
);
|
|
assert.equal(
|
|
recoveredCoverageGraph.historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY],
|
|
null,
|
|
);
|
|
const recoveredCoverageRecoveryPoint = findJournalRecoveryPoint(
|
|
recoveredCoverageGraph,
|
|
2,
|
|
);
|
|
assert.ok(recoveredCoverageRecoveryPoint);
|
|
assert.equal(recoveredCoverageRecoveryPoint.path, "reverse-journal");
|
|
assert.equal(recoveredCoverageRecoveryPoint.affectedJournals.length, 4);
|
|
|
|
rollbackBatch(graph, recoveryPoint.affectedJournals[0]);
|
|
assert.equal(graph.nodes.length, 0);
|
|
assert.equal(graph.historyState.lastProcessedAssistantFloor, -1);
|
|
assert.equal(graph.historyState.extractionCount, 0);
|
|
assert.equal(
|
|
Object.keys(graph.knowledgeState?.owners || {}).length,
|
|
0,
|
|
);
|
|
|
|
console.log("runtime-history tests passed");
|